init:taskbot 1.1.0

This commit is contained in:
2026-01-29 15:38:49 +08:00
commit 4dcb117601
25 changed files with 6070 additions and 0 deletions

56
src/config/env.js Normal file
View File

@@ -0,0 +1,56 @@
require('dotenv').config();
const config = {
app: {
port: process.env.PORT || 3000,
dbPath: process.env.DB_FILE_PATH || './sync.sqlite',
rate : process.env.RATE_LIMIT_WINDOW || 10000,
maxRequests: process.env.MAX_REQUESTS_PER_WINDOW || 20,
debugMode: process.env.DEBUG_MODE === 'true',
logRetentionDays: process.env.LOG_RETENTION_DAYS || 30
},
gitea: {
baseUrl: process.env.GITEA_BASE_URL,
token: process.env.GITEA_TOKEN,
secret: process.env.GITEA_WEBHOOK_SECRET,
botId: parseInt(process.env.GITEA_BOT_ID || '0'),
botName: process.env.GITEA_BOT_NAME
},
jira: {
baseUrl: process.env.JIRA_BASE_URL,
botId: process.env.JIRA_BOT_ID || '',
botName: process.env.JIRA_BOT_NAME
}
};
const requiredVars = [
{ key: 'GITEA_BASE_URL', value: config.gitea.baseUrl },
{ key: 'GITEA_TOKEN', value: config.gitea.token },
{ key: 'GITEA_WEBHOOK_SECRET', value: config.gitea.secret },
{ key: 'JIRA_BASE_URL', value: config.jira.baseUrl }
];
const missingVars = requiredVars.filter(v => !v.value).map(v => v.key);
if (missingVars.length > 0) {
console.error(`[ERROR] Missing required environment variables: ${missingVars.join(', ')}`);
console.error('[ERROR] Please configure these in your .env file');
process.exit(1);
}
if (process.env.JIRA_PAT) {
//个人访问令牌鉴权
config.jira.authHeader = {
'Authorization': `Bearer ${process.env.JIRA_PAT}`
};
} else if (process.env.JIRA_USERNAME && process.env.JIRA_PASSWORD) {
//账号密码鉴权
const authString = Buffer.from(`${process.env.JIRA_USERNAME}:${process.env.JIRA_PASSWORD}`).toString('base64');
config.jira.authHeader = {
'Authorization': `Basic ${authString}`
};
} else {
console.error("[ERROR] Missing JIRA authentication: Please configure JIRA_PAT or JIRA_USERNAME/PASSWORD");
process.exit(1);
}
module.exports = config;

144
src/config/mappings.js Normal file
View File

@@ -0,0 +1,144 @@
const fs = require('fs');
const path = require('path');
// 读取配置文件路径
const configPath = path.join(__dirname, '../../mappings.json');
let mappingsConfig = null;
/**
* 加载映射配置文件
* @returns {Object} 配置对象
*/
function loadMappings() {
if (mappingsConfig) {
return mappingsConfig;
}
try {
const configContent = fs.readFileSync(configPath, 'utf8');
mappingsConfig = JSON.parse(configContent);
// 处理环境变量替换
processEnvVariables(mappingsConfig);
return mappingsConfig;
} catch (error) {
throw new Error(`无法加载映射配置文件 ${configPath}: ${error.message}`);
}
}
/**
* 递归处理配置中的环境变量
* @param {Object} obj 配置对象
*/
function processEnvVariables(obj) {
for (const key in obj) {
if (typeof obj[key] === 'string' && obj[key].startsWith('${') && obj[key].endsWith('}')) {
const envVar = obj[key].slice(2, -1);
obj[key] = process.env[envVar] || obj[key];
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
processEnvVariables(obj[key]);
}
}
}
/**
* 获取所有仓库配置
* @returns {Object} 仓库配置对象
*/
const repositories = new Proxy({}, {
get(target, prop) {
const config = loadMappings();
return config.repositories[prop];
},
ownKeys() {
const config = loadMappings();
return Object.keys(config.repositories);
},
getOwnPropertyDescriptor(target, prop) {
const config = loadMappings();
if (prop in config.repositories) {
return {
enumerable: true,
configurable: true
};
}
}
});
/**
* 获取默认映射配置
* @returns {Object} 默认映射配置
*/
const defaultMappings = new Proxy({}, {
get(target, prop) {
const config = loadMappings();
return config.defaultMappings[prop];
}
});
/**
* 获取仓库配置
* @param {string} repoFullName 仓库完整名称 (owner/repo)
* @returns {Object|null} 仓库配置对象,如果不存在返回 null
*/
function getRepoConfig(repoFullName) {
const config = loadMappings();
const repoConfig = config.repositories[repoFullName];
if (!repoConfig) {
return null;
}
return {
jira: repoConfig.jira,
priorities: repoConfig.priorities || config.defaultMappings.priorities,
types: repoConfig.types || config.defaultMappings.types,
sprints: repoConfig.sprints || config.defaultMappings.sprints,
transitions: repoConfig.transitions || config.defaultMappings.transitions
};
}
/**
* 获取所有已配置的仓库列表
* @returns {string[]} 仓库名称数组
*/
function getConfiguredRepos() {
const config = loadMappings();
return Object.keys(config.repositories);
}
/**
* 根据Jira项目Key反查对应的Gitea仓库
* 返回第一个匹配的仓库配置如果一个Jira项目对应多个Gitea仓库只返回第一个
* @param {string} jiraProjectKey Jira项目Key
* @returns {Object|null} 包含仓库Key和配置的对象如果不存在返回 null
*/
function getRepoByJiraProject(jiraProjectKey) {
const config = loadMappings();
for (const [repoKey, repoConfig] of Object.entries(config.repositories)) {
if (repoConfig.jira && repoConfig.jira.projectKey === jiraProjectKey) {
return {
repoKey,
config: {
jira: repoConfig.jira,
priorities: repoConfig.priorities || config.defaultMappings.priorities,
types: repoConfig.types || config.defaultMappings.types,
sprints: repoConfig.sprints || config.defaultMappings.sprints,
transitions: repoConfig.transitions || config.defaultMappings.transitions
}
};
}
}
return null;
}
module.exports = {
repositories,
defaultMappings,
getRepoConfig,
getConfiguredRepos,
getRepoByJiraProject
};

48
src/db/connection.js Normal file
View File

@@ -0,0 +1,48 @@
const Database = require('better-sqlite3');
const config = require('../config/env');
const path = require('path');
const logger = require('../utils/logger');
//自动创建数据库连接
const dbPath = config.app.dbPath || path.join(__dirname, '../../sync.sqlite');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS issue_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_key TEXT NOT NULL, -- 仓库标识 (owner/repo格式)
gitea_id INTEGER NOT NULL, -- Gitea Issue Number
jira_key TEXT NOT NULL, -- Jira Key (e.g., LTM-123)
jira_id TEXT NOT NULL, -- Jira Internal ID
UNIQUE(repo_key, gitea_id) -- 同一仓库的Issue不能重复
)
`);
const cleanup = () => {
try {
logger.info('Closing database connection...');
db.close();
logger.info('Database connection closed');
} catch (err) {
logger.error('Failed to close database', err.message);
}
};
process.on('SIGINT', () => {
cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(0);
});
process.on('exit', () => {
if (db.open) {
cleanup();
}
});
logger.info(`Database connected at ${dbPath}`);
module.exports = db;

33
src/db/issueMap.js Normal file
View File

@@ -0,0 +1,33 @@
const db = require('./connection');
//根据仓库标识和GiteaID查询Jira工单信息
function getJiraKey(repoKey, giteaId) {
const row = db.prepare('SELECT jira_key, jira_id FROM issue_mapping WHERE repo_key = ? AND gitea_id = ?').get(repoKey, giteaId);
return row || null; //返回{jira_key,jira_id}或null
}
//根据Jira工单Key查询Gitea信息
function getGiteaInfo(jiraKey) {
const row = db.prepare('SELECT repo_key, gitea_id FROM issue_mapping WHERE jira_key = ?').get(jiraKey);
return row || null;
}
//保存仓库Issue与Jira工单的映射关系
function saveMapping(repoKey, giteaId, jiraKey, jiraId) {
const stmt = db.prepare('INSERT OR IGNORE INTO issue_mapping (repo_key, gitea_id, jira_key, jira_id) VALUES (?, ?, ?, ?)');
stmt.run(repoKey, giteaId, jiraKey, jiraId);
}
//获取指定仓库的所有映射记录
function getMappingsByRepo(repoKey) {
const rows = db.prepare('SELECT gitea_id, jira_key, jira_id FROM issue_mapping WHERE repo_key = ?').all(repoKey);
return rows;
}
//统计各仓库的同步数量
function getStats() {
const rows = db.prepare('SELECT repo_key, COUNT(*) as count FROM issue_mapping GROUP BY repo_key').all();
return rows;
}
module.exports = { getJiraKey, getGiteaInfo, saveMapping, getMappingsByRepo, getStats };

63
src/logic/converter.js Normal file
View File

@@ -0,0 +1,63 @@
const j2m = require('j2m');
//根据仓库配置构建Jira工单字段
//repoConfig: 从mappings.getRepoConfig()获取的仓库配置
//返回null表示类型未配置不应同步
function buildJiraFields(title, body, labels, milestone, repoConfig) {
const { jira, priorities, types, sprints } = repoConfig;
let issueTypeId = null;
let priorityId = "3";
let typeFound = false;
//处理标签
if (labels && labels.length > 0) {
for (const label of labels) {
const name = label.name;
if (types[name]) {
issueTypeId = types[name];
typeFound = true;
}
if (priorities[name]) priorityId = priorities[name];
}
}
//如果没有找到匹配的类型标签,检查是否使用默认类型
if (!typeFound) {
if (jira.defaultType) {
issueTypeId = jira.defaultType;
} else {
//没有配置类型且没有默认类型返回null表示不同步
return null;
}
}
let sprintId = null;
if (milestone && sprints[milestone.title]) {
sprintId = sprints[milestone.title];
}
// 如果body为空给默认值,否则将Markdown转换为Jira Wiki Markup
let description = "No description";
if (body) {
description = j2m.toJ(body);
}
//Jira Payload
const fields = {
project: { id: jira.projectId },
summary: title,
description: description,
issuetype: { id: issueTypeId },
priority: { id: priorityId }
};
if (sprintId && jira.sprintField) {
fields[jira.sprintField] = sprintId;
}
return fields;
}
module.exports = { buildJiraFields };

View File

@@ -0,0 +1,452 @@
const dbMap = require('../db/issueMap');
const giteaService = require('../services/gitea');
const jiraService = require('../services/jira');
const { getRepoConfig, getRepoByJiraProject } = require('../config/mappings');
const logger = require('../utils/logger');
const config = require('../config/env');
const j2m = require('j2m');
const { checkCircuitBreaker } = require('../utils/circuitBreaker');
//判断是否为机器人用户
function isBotUser(user) {
if (!user) return false;
const { botId, botName } = config.jira;
//根据ID或Name判断满足其一即视为机器人
//确保botId非空字符串才进行比较
const idMatch = botId && botId.length > 0 && (user.accountId === botId || user.key === botId || user.name === botId);
const nameMatch = botName && botName.length > 0 && (user.name === botName || user.displayName === botName);
return !!(idMatch || nameMatch);
}
function findGiteaLabel(valueId, labelMap) {
return Object.keys(labelMap).find(key => labelMap[key] === valueId || labelMap[key] === String(valueId));
}
function findGiteaMilestone(sprintId, sprintMap) {
return Object.keys(sprintMap).find(key => sprintMap[key] === sprintId || sprintMap[key] === Number(sprintId));
}
//从标题中提取[类型名]格式的类型标识
function extractTypeFromTitle(title) {
if (!title) return null;
const match = title.match(/^\[([^\]]+)\]/);
return match ? match[1].trim() : null;
}
//根据类型名在类型映射表中查找对应的Gitea标签
function findLabelByTypeName(typeName, typesMap) {
if (!typeName || !typesMap) return null;
//遍历类型映射表查找标签名中包含类型名的Gitea标签
//例如:类型名"Bug"可以匹配到标签"类型/Bug"
for (const [label, mappedTypeId] of Object.entries(typesMap)) {
//移除标签前缀后比较(例如"类型/Bug" -> "Bug"
const labelParts = label.split('/');
const labelTypeName = labelParts.length > 1 ? labelParts[labelParts.length - 1] : label;
if (labelTypeName === typeName) {
return label;
}
}
return null;
}
//从Jira Sprint字符串中提取Sprint ID
//处理Java对象toString格式: "com.atlassian...Sprint@xxx[id=37,name=xxx,...]"
function parseSprintId(sprintData) {
if (!sprintData) return null;
// 如果是数组取最后一个当前活动Sprint
if (Array.isArray(sprintData)) {
if (sprintData.length === 0) return null;
return parseSprintId(sprintData[sprintData.length - 1]);
}
// 如果已经是对象直接返回id
if (typeof sprintData === 'object' && sprintData.id) {
return parseInt(sprintData.id);
}
// 如果是字符串Java对象toString用正则提取id
if (typeof sprintData === 'string') {
const match = sprintData.match(/\bid=(\d+)/);
if (match) {
return parseInt(match[1]);
}
}
return null;
}
//Jira到Gitea的反向同步逻辑
async function handleJiraHook(payload) {
const { issue, comment, webhookEvent, user, changelog } = payload;
//熔断机制检查
if (!checkCircuitBreaker()) {
return;
}
//防死循环:如果是机器人账号的操作则忽略
const actor = user || (comment ? comment.author : null);
if (actor && isBotUser(actor)) {
return;
}
if (!issue || !issue.key) {
logger.warn(`[JIRA->GITEA] Invalid payload: missing issue or issue.key`);
return;
}
//检测是否为/resync评论命令
//Jira的评论事件通常作为 jira:issue_updated 发送但会包含comment字段
const hasComment = !!comment;
const isResyncCommand = (hasComment && comment.body && comment.body.trim() === '/resync');
if (hasComment) {
logger.info(`[JIRA->GITEA] Comment detected in ${issue.key}, body: "${comment.body?.trim()}", isResync: ${isResyncCommand}`);
}
//检查标题是否包含#不同步标记
if (issue.fields && issue.fields.summary && issue.fields.summary.includes("#不同步")) {
if (!isResyncCommand) {
logger.info(`[JIRA->GITEA] Skipped ${issue.key}: title contains #不同步`);
return;
}
}
//查找映射关系
const mapping = dbMap.getGiteaInfo(issue.key);
logger.info(`[JIRA->GITEA] Processing ${issue.key}, event: ${webhookEvent}, isResync: ${isResyncCommand}, hasMapping: ${!!mapping}`);
//处理Jira工单创建事件 - 在Gitea创建对应工单
if (webhookEvent === 'jira:issue_created' || (isResyncCommand && !mapping)) {
logger.info(`[JIRA->GITEA] Entering create logic for ${issue.key}`);
if (mapping && !isResyncCommand) {
return;
}
//根据Jira项目Key查找对应的Gitea仓库
const projectKey = issue.fields.project.key;
const repoInfo = getRepoByJiraProject(projectKey);
if (!repoInfo) {
logger.warn(`[JIRA->GITEA] No repo configured for project ${projectKey}`);
return;
}
const [owner, repo] = repoInfo.repoKey.split('/');
//检查类型是否在配置中
//优先检查标题中的[类型名],如果存在且能匹配则允许创建
//否则检查Jira问题类型是否配置
let hasValidType = false;
const titleType = extractTypeFromTitle(issue.fields.summary);
if (titleType) {
const typeLabel = findLabelByTypeName(titleType, repoInfo.config.types);
if (typeLabel) {
hasValidType = true;
logger.info(`[${repoInfo.repoKey}] Type from title [${titleType}] is configured`);
}
}
if (!hasValidType && issue.fields.issuetype && issue.fields.issuetype.id) {
const issueTypeId = issue.fields.issuetype.id;
const typeLabel = findGiteaLabel(issueTypeId, repoInfo.config.types);
hasValidType = !!typeLabel;
}
if (!hasValidType) {
logger.info(`[${repoInfo.repoKey}] [JIRA->GITEA] Skipped ${issue.key}: no valid type found in title or issue type`);
return;
}
try {
const issueData = {
title: issue.fields.summary || 'Untitled',
body: issue.fields.description ? j2m.toM(issue.fields.description) : ''
};
//创建Gitea工单
const giteaIssue = await giteaService.createIssue(owner, repo, issueData);
//保存映射关系
dbMap.saveMapping(repoInfo.repoKey, giteaIssue.number, issue.key, issue.id);
logger.sync(`[${repoInfo.repoKey}] [JIRA->GITEA] Created #${giteaIssue.number} from ${issue.key}`);
//同步标签(类型和优先级)
try {
const labels = [];
//添加类型标签:优先从标题中提取[类型名]
let typeLabel = null;
const titleType = extractTypeFromTitle(issue.fields.summary);
if (titleType) {
typeLabel = findLabelByTypeName(titleType, repoInfo.config.types);
if (typeLabel) {
logger.info(`[${repoInfo.repoKey}] Using type from title: [${titleType}] -> ${typeLabel}`);
}
}
//如果标题中没有类型或找不到匹配使用Jira问题类型
if (!typeLabel && issue.fields.issuetype) {
typeLabel = findGiteaLabel(issue.fields.issuetype.id, repoInfo.config.types);
}
if (typeLabel) {
labels.push(typeLabel);
}
//添加优先级标签
if (issue.fields.priority) {
const priorityLabel = findGiteaLabel(issue.fields.priority.id, repoInfo.config.priorities);
if (priorityLabel) {
labels.push(priorityLabel);
}
}
//如果有标签则设置
if (labels.length > 0) {
await giteaService.replaceLabels(owner, repo, giteaIssue.number, labels);
}
} catch (err) {
logger.warn(`[${repoInfo.repoKey}] [JIRA->GITEA] Failed to sync labels after creation: ${err.message}`);
}
//通过评论记录来源链接
const jiraIssueUrl = `${config.jira.baseUrl}/browse/${issue.key}`;
const giteaWebUrl = config.gitea.baseUrl.replace(/\/api\/v1\/?$/, '');
// 使用repoInfo中的owner和repo确保使用当前配置的仓库名
const giteaIssueUrl = `${giteaWebUrl}/${owner}/${repo}/issues/${giteaIssue.number}`;
const successMsg = isResyncCommand
? `手动同步已补建Gitea工单 [#${giteaIssue.number}](${giteaIssueUrl})`
: `Gitea来源: ${giteaIssueUrl}\n由工单机器人创建`;
Promise.all([
//在Gitea工单上添加评论格式与Gitea->Jira一致
giteaService.addComment(owner, repo, giteaIssue.number,
`已由工单机器人同步至Jira[${issue.key}](${jiraIssueUrl})`
),
//在Jira工单上添加评论
jiraService.addComment(issue.key, successMsg)
]).catch(err => logger.error('Comment write-back failed', err.message));
} catch (error) {
logger.error(`[${repoInfo.repoKey}] [JIRA->GITEA] Failed to create issue: ${error.message}`);
}
return;
}
//其他事件需要已存在的映射关系
if (!mapping) {
logger.info(`[JIRA->GITEA] No mapping found for ${issue.key}, skipping non-create/non-resync event`);
return;
}
const [owner, repo] = mapping.repo_key.split('/');
const giteaId = mapping.gitea_id;
try {
const repoConfig = getRepoConfig(mapping.repo_key);
if (!repoConfig) {
logger.warn(`[${mapping.repo_key}] [JIRA->GITEA] Repository not configured`);
return;
}
//处理工单更新(状态/标题/描述/优先级/类型/迭代/经办人)
if ((webhookEvent === 'jira:issue_updated' && changelog) || isResyncCommand) {
logger.info(`[JIRA->GITEA] Entering update logic for ${issue.key}, isResync: ${isResyncCommand}`);
const updateData = {};
const changes = isResyncCommand ? [] : (changelog.items || []);
let needsUpdate = isResyncCommand; // resync时强制更新
let needsLabelUpdate = isResyncCommand; // resync时强制更新标签
let needsMilestoneUpdate = isResyncCommand; // resync时强制更新里程碑
//获取当前Gitea工单详情以处理标签
let currentGiteaIssue = null;
//检查是否配置了状态流转映射
const hasTransitions = repoConfig.transitions && (repoConfig.transitions.close || repoConfig.transitions.reopen);
//如果是resync命令强制同步所有字段
if (isResyncCommand) {
//同步标题
if (issue.fields.summary) {
updateData.title = issue.fields.summary;
}
//同步描述
if (issue.fields.description) {
updateData.body = j2m.toM(issue.fields.description);
} else {
updateData.body = '';
}
//同步状态(仅在配置了状态流转时)
if (hasTransitions) {
const statusCategory = issue.fields.status?.statusCategory?.key;
if (statusCategory === 'done') {
updateData.state = 'closed';
} else {
updateData.state = 'open';
}
}
//同步经办人
if (issue.fields.assignee) {
updateData.assignees = [issue.fields.assignee.name];
} else {
updateData.assignees = [];
}
}
for (const item of changes) {
if (item.field === 'status' && hasTransitions) {
//根据statusCategory判断开关状态仅在配置了状态流转时
const statusCategory = issue.fields.status.statusCategory.key;
if (statusCategory === 'done') {
updateData.state = 'closed';
} else {
updateData.state = 'open';
}
needsUpdate = true;
}
if (item.field === 'summary') {
updateData.title = item.to;
needsUpdate = true;
}
if (item.field === 'description') {
const desc = item.toString || "";
updateData.body = j2m.toM(desc);
needsUpdate = true;
}
if (item.field === 'assignee') {
if (item.to) {
const assigneeName = issue.fields.assignee ? issue.fields.assignee.name : null;
if (assigneeName) {
updateData.assignees = [assigneeName];
needsUpdate = true;
}
} else {
updateData.assignees = [];
needsUpdate = true;
}
}
if (item.field === 'priority') {
needsLabelUpdate = true;
}
if (item.field === 'issuetype') {
needsLabelUpdate = true;
}
if (item.field === 'Sprint' || item.fieldId === 'customfield_10105') {
needsMilestoneUpdate = true;
}
}
//处理标签
if (needsLabelUpdate) {
try {
currentGiteaIssue = currentGiteaIssue || await giteaService.getIssue(owner, repo, giteaId);
if (currentGiteaIssue) {
//获取所有映射的标签名
const priorityLabels = Object.keys(repoConfig.priorities);
const typeLabels = Object.keys(repoConfig.types);
const mappedLabels = [...priorityLabels, ...typeLabels];
//保留非映射标签
let newLabels = currentGiteaIssue.labels
.map(l => l.name)
.filter(name => !mappedLabels.includes(name));
//添加新的优先级标签
if (issue.fields.priority) {
const priorityLabel = findGiteaLabel(issue.fields.priority.id, repoConfig.priorities);
if (priorityLabel) newLabels.push(priorityLabel);
}
//添加新的类型标签:优先从标题中提取[类型名]
let typeLabel = null;
const titleType = extractTypeFromTitle(issue.fields.summary);
if (titleType) {
typeLabel = findLabelByTypeName(titleType, repoConfig.types);
if (typeLabel) {
logger.info(`[${mapping.repo_key}] Using type from title: [${titleType}] -> ${typeLabel}`);
}
}
//如果标题中没有类型或找不到匹配使用Jira问题类型
if (!typeLabel && issue.fields.issuetype) {
typeLabel = findGiteaLabel(issue.fields.issuetype.id, repoConfig.types);
}
if (typeLabel) {
newLabels.push(typeLabel);
} else if (issue.fields.issuetype) {
logger.info(`[${mapping.repo_key}] [JIRA->GITEA] Type ${issue.fields.issuetype.id} not configured, skipping type label`);
}
//使用专门的标签API替换标签
await giteaService.replaceLabels(owner, repo, giteaId, newLabels);
}
} catch (err) {
logger.warn(`[${mapping.repo_key}] [JIRA->GITEA] Failed to sync labels: ${err.message}`);
}
}
//处理里程碑逻辑(Sprint->Milestone)
if (needsMilestoneUpdate) {
try {
//获取Sprint字段值
const sprintField = repoConfig.jira.sprintField || 'customfield_10105';
const sprintData = issue.fields[sprintField];
if (sprintData) {
const sprintId = parseSprintId(sprintData);
if (sprintId) {
const milestoneName = findGiteaMilestone(sprintId, repoConfig.sprints);
if (milestoneName) {
const milestones = await giteaService.getMilestones(owner, repo);
const milestone = milestones.find(m => m.title === milestoneName);
if (milestone) {
updateData.milestone = milestone.id;
needsUpdate = true;
} else {
logger.warn(`[${mapping.repo_key}] [JIRA->GITEA] Milestone "${milestoneName}" not found`);
}
}
}
} else {
updateData.milestone = 0;
needsUpdate = true;
}
} catch (err) {
logger.warn(`[${mapping.repo_key}] [JIRA->GITEA] Failed to sync milestone: ${err.message}`);
}
}
if (needsUpdate) {
await giteaService.updateIssue(owner, repo, giteaId, updateData);
logger.sync(`[${mapping.repo_key}] [JIRA->GITEA] Updated #${giteaId}`);
//如果是resync命令添加反馈评论
if (isResyncCommand) {
const giteaWebUrl = config.gitea.baseUrl.replace(/\/api\/v1\/?$/, '');
const giteaIssueUrl = `${giteaWebUrl}/${owner}/${repo}/issues/${giteaId}`;
await jiraService.addComment(issue.key,
`手动同步:工单已存在,已更新 [Gitea #${giteaId}](${giteaIssueUrl})`
).catch(err => logger.error('Comment write-back failed', err.message));
}
}
}
} catch (error) {
logger.error(`[${mapping.repo_key}] [JIRA->GITEA] Failed to sync ${issue.key}: ${error.message}`);
}
}
module.exports = { handleJiraHook };

308
src/logic/syncManager.js Normal file
View File

@@ -0,0 +1,308 @@
const dbMap = require('../db/issueMap');
const jiraService = require('../services/jira');
const giteaService = require('../services/gitea');
const { buildJiraFields } = require('./converter');
const { getRepoConfig } = require('../config/mappings');
const logger = require('../utils/logger');
const config = require('../config/env');
const { checkCircuitBreaker } = require('../utils/circuitBreaker');
// 处理Gitea Issue事件的主逻辑
const processingIds = new Set();
const LOCK_TIMEOUT = 10000;
const RETRY_DELAY = 1500;
const MAX_RETRIES = 3;
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
//生成用于锁定和数据库查询的唯一标识
function getIssueKey(repoKey, giteaId) {
return `${repoKey}#${giteaId}`;
}
//Gitea机器人检测
function isGiteaBot(sender) {
if (!sender) return false;
const { botId, botName } = config.gitea;
//ID和昵称二者只要有一个符合就判定为机器人
const idMatch = botId && sender.id === botId;
const nameMatch = botName && sender.username === botName;
return !!(idMatch || nameMatch);
}
async function handleIssueEvent(payload, retryCount = 0) {
const { action, issue, repository, comment, sender } = payload;
//如果操作者是机器人,直接忽略
if (isGiteaBot(sender)) {
return;
}
// 验证payload完整性
if (!issue || !issue.number || !repository) {
logger.error('Invalid payload: missing required fields');
return;
}
//熔断机制检查
if (!checkCircuitBreaker()) {
return;
}
//构建仓库标识 (owner/repo格式)
const repoKey = `${repository.owner.username}/${repository.name}`;
//获取该仓库的配置
const repoConfig = getRepoConfig(repoKey);
if (!repoConfig) {
const errorMsg = `Repository "${repoKey}" is not configured in mappings.js`;
logger.error(errorMsg);
throw new Error(errorMsg);
}
//检测是否为/resync
const isResyncCommand = (action === 'created' && comment && comment.body.trim() === '/resync');
const giteaId = issue.number;
const issueKey = getIssueKey(repoKey, giteaId);
if (issue.title && issue.title.includes("#不同步")) {
if (!isResyncCommand) {
logger.info(`[GITEA->JIRA] Skipped ${issue.key}: title contains #不同步`);
return;
}
}
//只处理特定的动作类型
const allowedActions = ['opened', 'label_updated', 'milestoned', 'demilestoned', 'edited', 'closed', 'reopened', 'assigned', 'unassigned'];
if (!allowedActions.includes(action) && !isResyncCommand) return;
//检查是否已被其他进程锁定
if (processingIds.has(issueKey)) {
if (retryCount >= MAX_RETRIES) {
logger.warn(`Lock timeout for ${repoKey}#${giteaId} after ${MAX_RETRIES} retries`, { action });
return;
}
logger.info(`Issue ${repoKey}#${giteaId} is locked. Retry ${retryCount + 1}/${MAX_RETRIES} in ${RETRY_DELAY}ms...`);
await sleep(RETRY_DELAY);
return handleIssueEvent(payload, retryCount + 1);
}
//加锁并设置超时自动释放
processingIds.add(issueKey);
const lockTimer = setTimeout(() => {
if (processingIds.has(issueKey)) {
logger.warn(`Force releasing lock for ${repoKey}#${giteaId} after ${LOCK_TIMEOUT}ms timeout`);
processingIds.delete(issueKey);
}
}, LOCK_TIMEOUT);
try {
const mapping = dbMap.getJiraKey(repoKey, giteaId);
const { transitions } = repoConfig;
//只有已存在的工单才能进行更新/关闭/重开操作
if (mapping) {
//处理关闭事件
if (action === 'closed') {
if (transitions && transitions.close) {
logger.sync(`[${repoKey}] [GITEA->JIRA] Closed ${mapping.jira_key}`);
await jiraService.transitionIssue(mapping.jira_key, transitions.close);
} else {
//未配置状态流转,跳过关闭操作
logger.info(`[${repoKey}] [GITEA->JIRA] Skipped close action for ${mapping.jira_key}: transitions.close not configured`);
}
}
//处理重开事件
else if (action === 'reopened') {
if (transitions && transitions.reopen) {
logger.sync(`[${repoKey}] [GITEA->JIRA] Reopened ${mapping.jira_key}`);
await jiraService.transitionIssue(mapping.jira_key, transitions.reopen);
} else {
//未配置状态流转,跳过重开操作
logger.info(`[${repoKey}] [GITEA->JIRA] Skipped reopen action for ${mapping.jira_key}: transitions.reopen not configured`);
}
}
//处理指派,同步经办人并流转状态
else if (action === 'assigned') {
//Gitea支持多个指派人Jira只支持一个经办人
//获取完整的issue信息来获取所有指派人并同步第一个到Jira
try {
const fullIssue = await giteaService.getIssue(
repository.owner.username,
repository.name,
giteaId
);
let assigneeToSync = null;
if (fullIssue.assignees && fullIssue.assignees.length > 0) {
assigneeToSync = fullIssue.assignees[0];
} else if (fullIssue.assignee) {
assigneeToSync = fullIssue.assignee;
}
if (assigneeToSync) {
const jiraUser = await jiraService.findUser(assigneeToSync.username);
if (jiraUser) {
logger.sync(`[${repoKey}] [GITEA->JIRA] Assigned ${mapping.jira_key} to ${jiraUser.name}`);
await jiraService.updateIssue(mapping.jira_key, { assignee: { name: jiraUser.name } });
}
}
if (transitions && transitions.in_progress) {
await jiraService.transitionIssue(mapping.jira_key, transitions.in_progress);
}
} catch (error) {
logger.error(`[${repoKey}] Failed to handle assigned event for ${mapping.jira_key}:`, error.message);
}
}
else if (action === 'unassigned') {
//Gitea 支持多个指派人Jira只支持一个经办人
//当移除指派人时,需要检查是否还有其他指派人
//如果还有保持第一个指派人同步到Jira如果没有了才清空Jira的经办人
try {
const fullIssue = await giteaService.getIssue(
repository.owner.username,
repository.name,
giteaId
);
if (fullIssue.assignees && fullIssue.assignees.length > 0) {
const firstAssignee = fullIssue.assignees[0];
const jiraUser = await jiraService.findUser(firstAssignee.username);
if (jiraUser) {
logger.sync(`[${repoKey}] [GITEA->JIRA] ${mapping.jira_key} still has assignees, keeping first: ${jiraUser.name}`);
await jiraService.updateIssue(mapping.jira_key, { assignee: { name: jiraUser.name } });
}
} else {
//没有指派人了清空Jira的经办人
logger.sync(`[${repoKey}] [GITEA->JIRA] Unassigned ${mapping.jira_key} (no assignees left)`);
await jiraService.updateIssue(mapping.jira_key, { assignee: null });
}
} catch (error) {
logger.error(`[${repoKey}] Failed to handle unassigned event for ${mapping.jira_key}:`, error.message);
}
}
else if (isResyncCommand || (!['closed', 'reopened', 'assigned', 'unassigned'].includes(action))) {
const jiraFields = buildJiraFields(
issue.title,
issue.body,
issue.labels,
issue.milestone,
repoConfig
);
//已有映射的工单,即使类型未配置也允许同步(只是不更新类型字段)
//这样当类型从未配置变为已配置时,也能正常同步
if (jiraFields) {
// 处理指派人同步resync 时)
// Gitea 支持多个指派人Jira 只支持一个经办人,取第一个
if (isResyncCommand) {
if (issue.assignees && issue.assignees.length > 0) {
const firstAssignee = issue.assignees[0];
const jiraUser = await jiraService.findUser(firstAssignee.username);
if (jiraUser) jiraFields.assignee = { name: jiraUser.name };
} else if (issue.assignee) {
const jiraUser = await jiraService.findUser(issue.assignee.username);
if (jiraUser) jiraFields.assignee = { name: jiraUser.name };
}
}
logger.sync(`[${repoKey}] [GITEA->JIRA] Updated ${mapping.jira_key}`);
delete jiraFields.project;
await jiraService.updateIssue(mapping.jira_key, jiraFields);
} else {
//类型未配置但已有映射,记录信息但不中断
logger.info(`[${repoKey}] [GITEA->JIRA] #${giteaId} type not configured, skipping update`);
}
if (isResyncCommand) {
await giteaService.addComment(
repository.owner.username,
repository.name,
giteaId,
`手动同步:工单已存在,已更新 [${mapping.jira_key}](${config.jira.baseUrl}/browse/${mapping.jira_key})`
);
}
}
} else {
//场景B:不存在->创建Jira
//只有opened事件和resync命令可以创建新工单
if (!isResyncCommand && action !== 'opened') {
logger.info(`[${repoKey}] [GITEA->JIRA] Skipped #${giteaId}: no mapping exists and action is '${action}' (only 'opened' or resync can create)`);
return;
}
const jiraFields = buildJiraFields(
issue.title,
issue.body,
issue.labels,
issue.milestone,
repoConfig
);
//类型未配置,跳过同步
if (!jiraFields) {
logger.info(`[${repoKey}] [GITEA->JIRA] Skipped #${giteaId}: type not configured in mappings`);
return;
}
if (issue.assignees && issue.assignees.length > 0) {
const firstAssignee = issue.assignees[0];
const jiraUser = await jiraService.findUser(firstAssignee.username);
if (jiraUser) {
jiraFields.assignee = { name: jiraUser.name };
}
} else if (issue.assignee) {
const jiraUser = await jiraService.findUser(issue.assignee.username);
if (jiraUser) {
jiraFields.assignee = { name: jiraUser.name };
}
}
const newIssue = await jiraService.createIssue(jiraFields);
dbMap.saveMapping(repoKey, giteaId, newIssue.key, newIssue.id);
logger.sync(`[${repoKey}] [GITEA->JIRA] Created ${newIssue.key} from #${giteaId}`);
const hasAssignee = (issue.assignees && issue.assignees.length > 0) || issue.assignee;
if (hasAssignee && transitions.in_progress) {
await jiraService.transitionIssue(newIssue.key, transitions.in_progress)
.catch(e => logger.error('Initial transition failed', e.message));
}
const giteaWebUrl = config.gitea.baseUrl.replace(/\/api\/v1\/?$/, '');
const giteaIssueUrl = `${giteaWebUrl}/${repository.owner.username}/${repository.name}/issues/${giteaId}`;
const successMsg = isResyncCommand
? `手动同步已补建Jira工单 [${newIssue.key}](${config.jira.baseUrl}/browse/${newIssue.key})`
: `Jira来源[${newIssue.key}](${config.jira.baseUrl}/browse/${newIssue.key})\n由工单机器人创建`;
Promise.all([
jiraService.addComment(newIssue.key, `Gitea来源: ${giteaIssueUrl}\n由工单机器人创建`),
giteaService.addComment(
repository.owner.username,
repository.name,
giteaId,
successMsg
)
]).catch(err => logger.error('Comment write-back failed', err.message));
}
} catch (error) {
logger.error(`[${repoKey}] [GITEA->JIRA] Failed to sync #${giteaId}: ${error.message}`);
if (isResyncCommand) {
await giteaService.addComment(
repository.owner.username,
repository.name,
giteaId,
`同步失败: ${error.message}`
);
}
} finally {
clearTimeout(lockTimer);
processingIds.delete(issueKey);
}
}
module.exports = { handleIssueEvent };

535
src/routes/editor.js Normal file
View File

@@ -0,0 +1,535 @@
/**
* 映射关系编辑器路由模块
* 提供映射配置的 CRUD 操作和 Jira API 代理
*/
const { Hono } = require('hono');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const logger = require('../utils/logger');
const editor = new Hono();
const MAPPINGS_PATH = path.join(__dirname, '../../mappings.json');
const LOGS_DIR = path.join(__dirname, '../../logs');
const README_PATH = path.join(__dirname, '../../how-to-use.md');
editor.get('/status', (c) => {
try {
let repoCount = 0;
if (fs.existsSync(MAPPINGS_PATH)) {
const config = JSON.parse(fs.readFileSync(MAPPINGS_PATH, 'utf8'));
repoCount = Object.keys(config.repositories || {}).length;
}
const uptime = process.uptime();
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const uptimeStr = days > 0 ? `${days}${hours}小时` : `${hours}小时 ${minutes}分钟`;
const today = new Date().toISOString().split('T')[0];
const logFile = path.join(LOGS_DIR, `sync-${today}.log`);
let todaySyncs = 0;
let errorCount = 0;
let fatalCount = 0;
if (fs.existsSync(logFile)) {
const content = fs.readFileSync(logFile, 'utf8');
const createdMatches = content.match(/Created/g);
todaySyncs = createdMatches ? createdMatches.length : 0;
const errorMatches = content.match(/\[ERROR\]/g);
errorCount = errorMatches ? errorMatches.length : 0;
const fatalMatches = content.match(/\[FATAL\]/g);
fatalCount = fatalMatches ? fatalMatches.length : 0;
}
return c.json({
success: true,
status: 'running',
repoCount,
todaySyncs,
errorCount,
fatalCount,
uptime: uptimeStr
});
} catch (e) {
logger.error('[Editor] Get status error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//获取历史统计数据
editor.get('/history', (c) => {
try {
const history = [];
//读取最近7天的日志文件
for (let i = 0; i < 7; i++) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const logFile = path.join(LOGS_DIR, `sync-${dateStr}.log`);
if (fs.existsSync(logFile)) {
const content = fs.readFileSync(logFile, 'utf8');
//统计各项指标
const createdMatches = content.match(/Created/g);
const errorMatches = content.match(/\[ERROR\]/g);
const fatalMatches = content.match(/\[FATAL\]/g);
history.push({
date: dateStr,
syncs: createdMatches ? createdMatches.length : 0,
errors: errorMatches ? errorMatches.length : 0,
fatals: fatalMatches ? fatalMatches.length : 0
});
} else {
history.push({
date: dateStr,
syncs: 0,
errors: 0,
fatals: 0
});
}
}
return c.json({
success: true,
history: history.reverse() //从旧到新排序
});
} catch (e) {
logger.error('[Editor] Get history error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//获取当日日志
editor.get('/logs', (c) => {
try {
//获取今天的日志文件
const today = new Date().toISOString().split('T')[0]; //YYYY-MM-DD
const logFile = path.join(LOGS_DIR, `sync-${today}.log`);
if (!fs.existsSync(logFile)) {
return c.json({
success: true,
filename: `sync-${today}.log`,
logs: ['[INFO] 今日暂无日志记录']
});
}
//读取日志文件最后1000行
const content = fs.readFileSync(logFile, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
const recentLogs = lines.slice(-1000); //只返回最后1000行
return c.json({
success: true,
filename: `sync-${today}.log`,
logs: recentLogs
});
} catch (e) {
logger.error('[Editor] Get logs error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//清空当日日志
editor.post('/logs/clear', (c) => {
try {
const today = new Date().toISOString().split('T')[0];
const logFile = path.join(LOGS_DIR, `sync-${today}.log`);
if (fs.existsSync(logFile)) {
fs.writeFileSync(logFile, '', 'utf8');
logger.info('[Editor] Logs cleared');
}
return c.json({ success: true, message: '日志已清空' });
} catch (e) {
logger.error('[Editor] Clear logs error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//控制机器人(重启等)
editor.post('/control', async (c) => {
try {
const { action } = await c.req.json();
logger.info(`[Editor] Control action received: ${action}`);
//注意:实际的重启需要外部进程管理器(如 PM2
//这里只是记录日志
if (action === 'restart') {
logger.info('[Editor] Restart requested (requires PM2 or similar)');
return c.json({
success: true,
message: '重启请求已记录,请使用 PM2 或其他进程管理器执行重启'
});
}
return c.json({
success: false,
error: '不支持的操作'
});
} catch (e) {
logger.error('[Editor] Control error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//读取 .env 文件
editor.get('/env', (c) => {
try {
const envPath = path.join(__dirname, '../../.env');
if (!fs.existsSync(envPath)) {
return c.json({
success: true,
content: '# 环境变量配置文件\n# 请根据需要配置以下变量\n'
});
}
const content = fs.readFileSync(envPath, 'utf8');
return c.json({ success: true, content });
} catch (e) {
logger.error('[Editor] Read .env error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//保存 .env 文件
editor.post('/env', async (c) => {
try {
const { content } = await c.req.json();
const envPath = path.join(__dirname, '../../.env');
//备份现有文件
if (fs.existsSync(envPath)) {
const backupPath = path.join(__dirname, '../../.env.backup');
fs.copyFileSync(envPath, backupPath);
logger.info('[Editor] .env file backed up');
}
//写入新内容
fs.writeFileSync(envPath, content, 'utf8');
logger.info('[Editor] .env file updated');
return c.json({
success: true,
message: '配置已保存,重启服务后生效'
});
} catch (e) {
logger.error('[Editor] Save .env error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
editor.get('/guide', (c) => {
try {
if (!fs.existsSync(README_PATH)) {
return c.json({
success: false,
error: 'how-to-use.md not found',
content: '# 使用指南\n\n使用指南文件不存在'
});
}
const content = fs.readFileSync(README_PATH, 'utf8');
return c.json({ success: true, content });
} catch (e) {
logger.error('[Editor] Read guide error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//读取现有的 mappings.json
editor.get('/mappings', (c) => {
try {
if (!fs.existsSync(MAPPINGS_PATH)) {
return c.json({ success: true, data: { repositories: {} } });
}
const content = fs.readFileSync(MAPPINGS_PATH, 'utf8');
const config = JSON.parse(content);
return c.json({ success: true, data: config });
} catch (e) {
logger.error('[Editor] Read mappings error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//保存/更新 mappings.json
editor.post('/mappings', async (c) => {
try {
const { repoName, config } = await c.req.json();
let fullConfig = { repositories: {} };
//读取现有配置
if (fs.existsSync(MAPPINGS_PATH)) {
const content = fs.readFileSync(MAPPINGS_PATH, 'utf8');
fullConfig = JSON.parse(content);
}
//确保结构存在
if (!fullConfig.repositories) fullConfig.repositories = {};
//更新指定仓库的配置
fullConfig.repositories[repoName] = config;
//写回文件
fs.writeFileSync(MAPPINGS_PATH, JSON.stringify(fullConfig, null, 2), 'utf8');
logger.info(`[Editor] Updated configuration for ${repoName}`);
return c.json({ success: true, message: `配置已保存到 mappings.json` });
} catch (e) {
logger.error('[Editor] Save mappings error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//删除仓库配置
editor.delete('/mappings/:repoName', async (c) => {
try {
const repoName = decodeURIComponent(c.req.param('repoName'));
if (!fs.existsSync(MAPPINGS_PATH)) {
return c.json({ success: false, error: '配置文件不存在' }, 404);
}
//读取现有配置
const content = fs.readFileSync(MAPPINGS_PATH, 'utf8');
const fullConfig = JSON.parse(content);
//检查仓库是否存在
if (!fullConfig.repositories || !fullConfig.repositories[repoName]) {
return c.json({ success: false, error: '仓库配置不存在' }, 404);
}
//删除指定仓库
delete fullConfig.repositories[repoName];
//写回文件
fs.writeFileSync(MAPPINGS_PATH, JSON.stringify(fullConfig, null, 2), 'utf8');
logger.info(`[Editor] Deleted configuration for ${repoName}`);
return c.json({ success: true, message: `仓库配置已删除` });
} catch (e) {
logger.error('[Editor] Delete mappings error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//改名仓库配置
editor.post('/mappings/rename', async (c) => {
try {
const { oldName, newName } = await c.req.json();
if (!oldName || !newName) {
return c.json({ success: false, error: '缺少必要参数' }, 400);
}
if (!fs.existsSync(MAPPINGS_PATH)) {
return c.json({ success: false, error: '配置文件不存在' }, 404);
}
//读取现有配置
const content = fs.readFileSync(MAPPINGS_PATH, 'utf8');
const fullConfig = JSON.parse(content);
//检查旧名称是否存在
if (!fullConfig.repositories || !fullConfig.repositories[oldName]) {
return c.json({ success: false, error: '源仓库配置不存在' }, 404);
}
//检查新名称是否已存在
if (fullConfig.repositories[newName]) {
return c.json({ success: false, error: '目标仓库名称已存在' }, 400);
}
//复制配置到新名称
fullConfig.repositories[newName] = fullConfig.repositories[oldName];
//删除旧名称
delete fullConfig.repositories[oldName];
//写回文件
fs.writeFileSync(MAPPINGS_PATH, JSON.stringify(fullConfig, null, 2), 'utf8');
logger.info(`[Editor] Renamed configuration from ${oldName} to ${newName}`);
return c.json({ success: true, message: `仓库配置已改名` });
} catch (e) {
logger.error('[Editor] Rename mappings error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//保存配置接口(兼容旧版)
editor.post('/save', async (c) => {
try {
const newConfigObj = await c.req.json();
const repoName = Object.keys(newConfigObj)[0];
const repoData = newConfigObj[repoName];
let fullConfig = { repositories: {}, defaultMappings: {} };
//1. 读取现有文件(保留 guide, comment 等字段)
if (fs.existsSync(MAPPINGS_PATH)) {
try {
const content = fs.readFileSync(MAPPINGS_PATH, 'utf8');
fullConfig = JSON.parse(content);
} catch (e) {
logger.error("[Editor] JSON Parse Error, creating backup", e.message);
fs.copyFileSync(MAPPINGS_PATH, MAPPINGS_PATH + '.bak');
}
}
//2. 确保结构存在
if (!fullConfig.repositories) fullConfig.repositories = {};
//3. 更新特定仓库
fullConfig.repositories[repoName] = repoData;
//4. 写回文件
fs.writeFileSync(MAPPINGS_PATH, JSON.stringify(fullConfig, null, 2), 'utf8');
logger.info(`[Editor] Saved configuration for ${repoName}`);
return c.json({ success: true });
} catch (error) {
logger.error("[Editor] Save Error:", error.message);
return c.json({ success: false, error: error.message }, 500);
}
});
//扫描 Jira 项目信息
editor.post('/scan', async (c) => {
const { baseUrl, auth, projectKey: rawKey } = await c.req.json();
const inputKey = rawKey ? rawKey.trim() : '';
//构造认证头
let headers = { 'Accept': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
else if (auth.username) headers['Authorization'] = `Basic ${Buffer.from(auth.username + ':' + auth.password).toString('base64')}`;
const client = axios.create({ baseURL: baseUrl.replace(/\/$/, ''), headers, timeout: 15000 });
try {
let projectData, realKey = inputKey;
//尝试获取项目
try {
projectData = (await client.get(`/rest/api/2/project/${inputKey}`)).data;
} catch (e) {
if (e.response?.status === 404) {
//404 尝试列表搜索
const list = (await client.get('/rest/api/2/project')).data;
const found = list.find(p => p.key.toLowerCase() === inputKey.toLowerCase());
if (!found) throw new Error("Project not found (Check permissions/key)");
projectData = (await client.get(`/rest/api/2/project/${found.id}`)).data;
realKey = found.key;
} else throw e;
}
const result = {
project: { id: projectData.id, key: projectData.key, name: projectData.name },
types: projectData.issueTypes.filter(t => !t.subtask).map(t => ({ id: t.id, name: t.name, iconUrl: t.iconUrl })),
priorities: (await client.get('/rest/api/2/priority')).data.map(p => ({ id: p.id, name: p.name, iconUrl: p.iconUrl })),
transitions: [], sampleIssueKey: null
};
//尝试获取流转 - 从不同状态的工单收集所有可能的流转
const transitionsMap = new Map();
let sampleIssues = [];
try {
//获取多个工单以覆盖不同状态
const search = await client.get(`/rest/api/2/search?jql=project="${realKey}"&maxResults=20&fields=id,key,status`);
if (search.data.issues?.length > 0) {
sampleIssues = search.data.issues;
result.sampleIssueKey = sampleIssues[0].key;
//对每个工单获取其可用的transitions
const transPromises = sampleIssues.slice(0, 15).map(issue =>
client.get(`/rest/api/2/issue/${issue.key}/transitions`)
.then(trans => trans.data.transitions)
.catch(() => [])
);
const allTransitions = await Promise.all(transPromises);
//合并所有transitions并去重
allTransitions.flat().forEach(t => {
if (!transitionsMap.has(t.id)) {
transitionsMap.set(t.id, { id: t.id, name: t.name, to: t.to?.name || 'Unknown' });
}
});
result.transitions = Array.from(transitionsMap.values());
if (result.transitions.length > 0) {
result.warning = `已从 ${sampleIssues.length} 个工单中扫描到 ${result.transitions.length} 个状态流转。注意Jira的流转取决于工单当前状态未被扫描的必须手动配置。`;
}
}
} catch (e) {
result.warning = `无法获取完整的状态流转信息: ${e.message}`;
}
return c.json({ success: true, data: result });
} catch (e) {
return c.json({ success: false, error: e.message }, 500);
}
});
//扫描 Sprint 信息
editor.post('/scan-sprint', async (c) => {
const { baseUrl, auth, issueKey } = await c.req.json();
let headers = { 'Accept': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
else if (auth.username) headers['Authorization'] = `Basic ${Buffer.from(auth.username + ':' + auth.password).toString('base64')}`;
try {
const client = axios.create({ baseURL: baseUrl.replace(/\/$/, ''), headers, timeout: 10000 });
const fields = (await client.get(`/rest/api/2/issue/${issueKey}`)).data.fields;
let fieldId = null, sprints = [];
for (const [k, v] of Object.entries(fields)) {
if (Array.isArray(v) && v[0]?.toString().includes('com.atlassian.greenhopper.service.sprint.Sprint')) {
fieldId = k;
v.forEach(s => {
const id = s.match(/id=(\d+)/)?.[1];
const name = s.match(/name=([^,\]]+)/)?.[1];
if (id && name) sprints.push({ id, name });
});
}
}
return c.json({ success: true, data: { fieldId, sprints } });
} catch (e) {
return c.json({ success: false, error: e.message }, 500);
}
});
//代理 Jira API 请求
editor.post('/proxy-jira', async (c) => {
const { url, auth } = await c.req.json();
try {
const response = await axios.get(url, {
headers: {
'Authorization': auth,
'Accept': 'application/json'
},
timeout: 10000
});
return c.json({ success: true, data: response.data });
} catch (e) {
logger.error('[Editor] Proxy Jira Error:', e.message);
return c.json({
success: false,
error: e.response?.data?.errorMessages?.[0] || e.message
}, 500);
}
});
module.exports = editor;

87
src/services/gitea.js Normal file
View File

@@ -0,0 +1,87 @@
const axios = require('axios');
const config = require('../config/env');
const logger = require('../utils/logger');
const giteaClient = axios.create({
baseURL: config.gitea.baseUrl,
headers: {
'Authorization': `token ${config.gitea.token}`,
'Content-Type': 'application/json'
}
});
//回写评论
async function addComment(repoOwner, repoName, issueIndex, body) {
try {
await giteaClient.post(`/repos/${repoOwner}/${repoName}/issues/${issueIndex}/comments`, { body });
logger.info(`Gitea commented on #${issueIndex}`);
} catch (error) {
logger.error(`Gitea add comment failed (#${issueIndex})`, error.message);
}
}
//更新工单
async function updateIssue(repoOwner, repoName, issueIndex, data) {
try {
await giteaClient.patch(`/repos/${repoOwner}/${repoName}/issues/${issueIndex}`, data);
logger.info(`Gitea updated issue #${issueIndex}`);
} catch (error) {
logger.error(`Gitea update failed (#${issueIndex})`, error.message);
}
}
//创建工单用于Jira->Gitea反向同步
async function createIssue(repoOwner, repoName, data) {
try {
const response = await giteaClient.post(`/repos/${repoOwner}/${repoName}/issues`, data);
logger.info(`Gitea created issue #${response.data.number} in ${repoOwner}/${repoName}`);
return response.data;
} catch (error) {
logger.error(`Gitea create issue failed`, error.message);
throw error;
}
}
//获取工单详情
async function getIssue(repoOwner, repoName, issueIndex) {
try {
const response = await giteaClient.get(`/repos/${repoOwner}/${repoName}/issues/${issueIndex}`);
return response.data;
} catch (error) {
logger.error(`Gitea get issue failed (#${issueIndex})`, error.message);
return null;
}
}
//获取仓库的所有里程碑
async function getMilestones(repoOwner, repoName) {
try {
const response = await giteaClient.get(`/repos/${repoOwner}/${repoName}/milestones`, {
params: { state: 'all' }
});
return response.data || [];
} catch (error) {
logger.error(`Gitea get milestones failed`, error.message);
return [];
}
}
//替换工单标签 (Gitea API需要使用专门的标签接口)
async function replaceLabels(repoOwner, repoName, issueIndex, labelNames) {
try {
//Gitea API: PUT /repos/{owner}/{repo}/issues/{index}/labels
//需要传递 { labels: [label_id, ...] } 或 { labels: ["label_name", ...] }
//不同版本API可能不同尝试使用labels数组
const response = await giteaClient.put(
`/repos/${repoOwner}/${repoName}/issues/${issueIndex}/labels`,
{ labels: labelNames }
);
logger.info(`Gitea replaced labels on #${issueIndex}: ${labelNames.join(', ')}`);
return response.data;
} catch (error) {
logger.error(`Gitea replace labels failed (#${issueIndex})`, error.response?.data || error.message);
throw error;
}
}
module.exports = { addComment, updateIssue, createIssue, getIssue, getMilestones, replaceLabels };

93
src/services/jira.js Normal file
View File

@@ -0,0 +1,93 @@
const axios = require('axios');
const config = require('../config/env');
const logger = require('../utils/logger');
//创建Jira API客户端(共用认证信息baseURL来自全局配置)
const jiraClient = axios.create({
baseURL: config.jira.baseUrl,
headers: {
...config.jira.authHeader,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
//创建工单
async function createIssue(fields) {
try {
const res = await jiraClient.post('/rest/api/2/issue', { fields });
logger.info(`Jira created issue: ${res.data.key}`);
return res.data;
} catch (error) {
logger.error('Jira create issue failed', error.response?.data || error.message);
throw error;
}
}
//更新工单
async function updateIssue(key, fields) {
try {
await jiraClient.put(`/rest/api/2/issue/${key}`, { fields });
logger.info(`Jira updated ${key}`);
} catch (error) {
logger.error(`Jira update issue failed (${key})`, error.response?.data || error.message);
throw error;
}
}
//执行状态转换
async function transitionIssue(key, transitionId) {
try {
await jiraClient.post(`/rest/api/2/issue/${key}/transitions`, {
transition: { id: transitionId }
});
logger.info(`Jira transitioned ${key} to state ID ${transitionId}`);
} catch (error) {
logger.error(`Jira transition failed (${key})`, error.response?.data || error.message);
throw error;
}
}
//添加评论
async function addComment(key, body) {
try {
await jiraClient.post(`/rest/api/2/issue/${key}/comment`, { body });
logger.info(`Jira added comment to ${key}`);
} catch (error) {
logger.error(`Jira add comment failed (${key})`, error.message);
}
}
//查找用户,支持精确匹配和模糊匹配
async function findUser(query) {
if (!query) return null;
try {
const res = await jiraClient.get('/rest/api/2/user/search', {
params: {
username: query,
maxResults: 10
}
});
if (res.data && res.data.length > 0) {
const exactMatch = res.data.find(u =>
u.name === query ||
u.key === query ||
u.emailAddress === query ||
u.displayName === query
);
if (exactMatch) {
logger.info(`Found exact user match: ${exactMatch.name}`);
return exactMatch;
}
logger.info(`Using partial match for user: ${res.data[0].name}`);
return res.data[0];
}
logger.warn(`No user found for query: "${query}"`);
return null;
} catch (error) {
logger.warn(`User search failed for "${query}"`, error.message);
return null;
}
}
module.exports = { createIssue, updateIssue, addComment, transitionIssue, findUser };

View File

@@ -0,0 +1,34 @@
const logger = require('./logger');
const config = require('../config/env');
//全局限流配置
let requestCount = 0;
let lastResetTime = Date.now();
//熔断检查器
function checkCircuitBreaker() {
const now = Date.now();
if (now - lastResetTime > config.app.rate) {
requestCount = 0;
lastResetTime = now;
}
requestCount++;
if (requestCount > config.app.maxRequests) {
const msg = `Circuit breaker triggered: Exceeded ${config.app.maxRequests} requests in ${config.app.rate/1000}s. Exiting...`;
//同步写入fatal日志
logger.fatal("============================");
logger.fatal(`${msg}`);
logger.fatal("============================");
if (!config.app.debugMode) {
process.exit(1);
} else {
logger.warn('Debug mode enabled, not exiting.');
requestCount = 0;
}
return false;
}
return true;
}
module.exports = { checkCircuitBreaker };

149
src/utils/logger.js Normal file
View File

@@ -0,0 +1,149 @@
const config = require('../config/env');
const fs = require('fs');
const path = require('path');
//日志目录
const LOG_DIR = path.join(__dirname, '../../logs');
//确保日志目录存在
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
function getTimestamp() {
const date = new Date();
const utc8Time = new Date(date.getTime() + (8 * 60 * 60 * 1000));
const year = utc8Time.getUTCFullYear();
const month = String(utc8Time.getUTCMonth() + 1).padStart(2, '0');
const day = String(utc8Time.getUTCDate()).padStart(2, '0');
const hours = String(utc8Time.getUTCHours()).padStart(2, '0');
const minutes = String(utc8Time.getUTCMinutes()).padStart(2, '0');
const seconds = String(utc8Time.getUTCSeconds()).padStart(2, '0');
const ms = String(utc8Time.getUTCMilliseconds()).padStart(3, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}`;
}
//获取日志文件名
function getLogFileName() {
const date = new Date();
const utc8Time = new Date(date.getTime() + (8 * 60 * 60 * 1000));
const year = utc8Time.getUTCFullYear();
const month = String(utc8Time.getUTCMonth() + 1).padStart(2, '0');
const day = String(utc8Time.getUTCDate()).padStart(2, '0');
return `sync-${year}-${month}-${day}.log`;
}
//写入日志到文件
function writeLog(level, message, data = null) {
const timestamp = getTimestamp();
const logFile = path.join(LOG_DIR, getLogFileName());
let logLine = `[${timestamp}] [${level}] ${message}`;
if (data) {
if (typeof data === 'object') {
logLine += ' ' + JSON.stringify(data);
} else {
logLine += ' ' + data;
}
}
logLine += '\n';
//异步写入,不阻塞主流程
fs.appendFile(logFile, logLine, (err) => {
if (err) {
console.error('[ERROR] Logger failed to write log:', err.message);
}
});
}
//同步写入只用于输出fatal日志不要用于其它场景以免阻塞主流程
function writeLogSync(level, message, data = null) {
const timestamp = getTimestamp();
const logFile = path.join(LOG_DIR, getLogFileName());
let logLine = `[${timestamp}] [${level}] ${message}`;
if (data) {
if (typeof data === 'object') {
logLine += ' ' + JSON.stringify(data);
} else {
logLine += ' ' + data;
}
}
logLine += '\n';
try {
fs.appendFileSync(logFile, logLine);
} catch (err) {
console.error('[ERROR] Logger failed to write log:', err.message);
}
}
//日志级别函数
const logger = {
info: (message, data) => {
console.log(`[INFO] ${message}`);
writeLog('INFO', message, data);
},
warn: (message, data) => {
console.warn(`[WARN] ${message}`);
writeLog('WARN', message, data);
},
error: (message, data) => {
console.error(`[ERROR] ${message}`);
writeLog('ERROR', message, data);
},
security: (message, data) => {
console.warn(`[SECURITY] ${message}`);
writeLog('SECURITY', message, data);
},
sync: (message, data) => {
console.log(`[SYNC] ${message}`);
writeLog('SYNC', message, data);
},
fatal: (message, data) => {
console.error(`[FATAL] ${message}`);
writeLogSync('FATAL', message, data);
},
//清理旧日志
cleanOldLogs: (daysToKeep = 30) => {
try {
const files = fs.readdirSync(LOG_DIR);
const now = Date.now();
const maxAge = daysToKeep * 24 * 60 * 60 * 1000;
files.forEach(file => {
const filePath = path.join(LOG_DIR, file);
const stats = fs.statSync(filePath);
const age = now - stats.mtime.getTime();
if (age > maxAge && file.endsWith('.log')) {
fs.unlinkSync(filePath);
logger.info(`Deleted old log file: ${file}`);
}
});
} catch (error) {
console.error('[ERROR] Logger error cleaning old logs:', error.message);
}
}
};
//启动时清理旧日志
logger.cleanOldLogs(config.app.logRetentionDays);
//定时清理(每天执行一次)
setInterval(() => {
logger.cleanOldLogs(config.app.logRetentionDays);
}, 24 * 60 * 60 * 1000);
module.exports = logger;

View File

@@ -0,0 +1,196 @@
/**
* 测试工单清理脚本
* 清理所有标题包含 [TEST] 的测试工单
*/
const axios = require('axios');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../../../.env') });
const GITEA_API = process.env.GITEA_BASE_URL;
const GITEA_TOKEN = process.env.GITEA_TOKEN;
const JIRA_API = process.env.JIRA_BASE_URL;
const JIRA_AUTH = process.env.JIRA_PAT
? { 'Authorization': `Bearer ${process.env.JIRA_PAT}` }
: { 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USERNAME}:${process.env.JIRA_PASSWORD}`).toString('base64')}` };
const REPO = {
owner: 'loren',
repo: 'issueBotTest'
};
const JIRA_PROJECT_KEY = process.env.JIRA_PROJECT_KEY_1 || 'TEST';
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
cyan: '\x1b[36m'
};
function log(color, message) {
console.log(`${color}${message}${colors.reset}`);
}
async function cleanupGiteaIssues() {
log(colors.cyan, '\n[Gitea] 查找测试工单...');
try {
// 获取所有 open 状态的 issues
const openResponse = await axios.get(
`${GITEA_API}/repos/${REPO.owner}/${REPO.repo}/issues`,
{
headers: { 'Authorization': `token ${GITEA_TOKEN}` },
params: { state: 'open', per_page: 100 }
}
);
// 获取所有 closed 状态的 issues
const closedResponse = await axios.get(
`${GITEA_API}/repos/${REPO.owner}/${REPO.repo}/issues`,
{
headers: { 'Authorization': `token ${GITEA_TOKEN}` },
params: { state: 'closed', per_page: 100 }
}
);
const allIssues = [...openResponse.data, ...closedResponse.data];
const testIssues = allIssues.filter(issue =>
issue.title.includes('[TEST]') ||
issue.title.includes('测试') ||
issue.body?.includes('[自动化测试]')
);
log(colors.yellow, `找到 ${testIssues.length} 个测试工单`);
let closedCount = 0;
for (const issue of testIssues) {
try {
// 先打开(如果是关闭的)
if (issue.state === 'closed') {
await axios.patch(
`${GITEA_API}/repos/${REPO.owner}/${REPO.repo}/issues/${issue.number}`,
{ state: 'open' },
{ headers: { 'Authorization': `token ${GITEA_TOKEN}` } }
);
await new Promise(resolve => setTimeout(resolve, 50));
}
// 关闭工单
await axios.patch(
`${GITEA_API}/repos/${REPO.owner}/${REPO.repo}/issues/${issue.number}`,
{ state: 'closed' },
{ headers: { 'Authorization': `token ${GITEA_TOKEN}` } }
);
closedCount++;
log(colors.green, ` ✓ 关闭 #${issue.number}: ${issue.title}`);
// 避免过快请求
await new Promise(resolve => setTimeout(resolve, 50));
} catch (error) {
log(colors.red, ` ✗ 关闭失败 #${issue.number}: ${error.message}`);
}
}
log(colors.green, `\n[Gitea] 成功关闭 ${closedCount}/${testIssues.length} 个测试工单`);
return closedCount;
} catch (error) {
log(colors.red, `[Gitea] 清理失败: ${error.message}`);
return 0;
}
}
async function cleanupJiraIssues() {
log(colors.cyan, '\n[Jira] 查找测试工单...');
try {
// 使用 JQL 查询测试工单(使用 text ~ 进行全文搜索)
const jql = `project = ${JIRA_PROJECT_KEY} AND (text ~ "TEST" OR text ~ "测试" OR text ~ "自动化测试") ORDER BY created DESC`;
const searchResponse = await axios.get(
`${JIRA_API}/rest/api/2/search`,
{
headers: { ...JIRA_AUTH, 'Content-Type': 'application/json' },
params: { jql, maxResults: 200 }
}
);
const testIssues = searchResponse.data.issues || [];
log(colors.yellow, `找到 ${testIssues.length} 个测试工单`);
let closedCount = 0;
for (const issue of testIssues) {
try {
// 获取工单的转换选项
const transitionsResponse = await axios.get(
`${JIRA_API}/rest/api/2/issue/${issue.key}/transitions`,
{ headers: { ...JIRA_AUTH, 'Content-Type': 'application/json' } }
);
// 查找 "完成"、"Done"、"关闭" 等转换
const closeTransition = transitionsResponse.data.transitions.find(t =>
t.name === '完成' ||
t.name === 'Done' ||
t.name === '关闭' ||
t.to.name === 'Done' ||
t.to.statusCategory?.key === 'done'
);
if (closeTransition) {
await axios.post(
`${JIRA_API}/rest/api/2/issue/${issue.key}/transitions`,
{ transition: { id: closeTransition.id } },
{ headers: { ...JIRA_AUTH, 'Content-Type': 'application/json' } }
);
closedCount++;
log(colors.green, ` ✓ 关闭 ${issue.key}: ${issue.fields.summary}`);
} else {
log(colors.yellow, `${issue.key} 无可用的关闭转换`);
}
// 避免过快请求
await new Promise(resolve => setTimeout(resolve, 50));
} catch (error) {
log(colors.red, ` ✗ 关闭失败 ${issue.key}: ${error.message}`);
}
}
log(colors.green, `\n[Jira] 成功关闭 ${closedCount}/${testIssues.length} 个测试工单`);
return closedCount;
} catch (error) {
log(colors.red, `[Jira] 清理失败: ${error.message}`);
if (error.response) {
log(colors.red, ` 状态码: ${error.response.status}`);
log(colors.red, ` 响应: ${JSON.stringify(error.response.data)}`);
}
return 0;
}
}
async function main() {
log(colors.cyan, '========================================');
log(colors.cyan, ' 测试工单清理脚本');
log(colors.cyan, '========================================\n');
log(colors.yellow, '警告: 此脚本将关闭所有标题包含 [TEST] 或 "测试" 的工单');
log(colors.yellow, '按 Ctrl+C 取消,或等待 3 秒后开始...\n');
await new Promise(resolve => setTimeout(resolve, 3000));
const giteaClosed = await cleanupGiteaIssues();
const jiraClosed = await cleanupJiraIssues();
log(colors.cyan, '\n========================================');
log(colors.cyan, '清理完成');
log(colors.cyan, '========================================');
log(colors.green, `Gitea: 关闭 ${giteaClosed} 个工单`);
log(colors.green, `Jira: 关闭 ${jiraClosed} 个工单`);
}
main().catch(error => {
log(colors.red, `\n脚本执行失败: ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,614 @@
const crypto = require('crypto');
const axios = require('axios');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../../../.env') });
// 配置 - 使用真实仓库
const BASE_URL = 'http://localhost:3000';
const WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET;
const JIRA_BASE_URL = process.env.JIRA_BASE_URL;
const GITEA_BASE_URL = process.env.GITEA_BASE_URL;
// 使用 mappings.js 中配置的真实仓库
const REAL_REPO = {
owner: 'loren',
repo: 'issueBotTest',
fullName: 'loren/issueBotTest'
};
const JIRA_PROJECT = {
key: process.env.JIRA_PROJECT_KEY_1 || 'TEST',
id: process.env.JIRA_PROJECT_ID_1 || '10000'
};
// 测试统计
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
const testResults = [];
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
magenta: '\x1b[35m',
gray: '\x1b[90m'
};
function log(color, message) {
console.log(`${color}${message}${colors.reset}`);
}
function createSignature(payload) {
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
return hmac.update(JSON.stringify(payload)).digest('hex');
}
async function sendGiteaWebhook(payload, signature = null, useSignature = true) {
const headers = {
'Content-Type': 'application/json',
'X-Gitea-Event': 'issues'
};
if (useSignature) {
headers['X-Gitea-Signature'] = signature || createSignature(payload);
}
try {
const response = await axios.post(`${BASE_URL}/hooks/gitea`, payload, {
headers,
timeout: 10000
});
return { success: true, status: response.status, data: response.data };
} catch (error) {
return {
success: false,
status: error.response?.status,
data: error.response?.data,
error: error.message
};
}
}
async function sendJiraWebhook(payload) {
try {
const response = await axios.post(`${BASE_URL}/hooks/jira`, payload, {
headers: { 'Content-Type': 'application/json' },
timeout: 10000
});
return { success: true, status: response.status, data: response.data };
} catch (error) {
return {
success: false,
status: error.response?.status,
data: error.response?.data,
error: error.message
};
}
}
function createGiteaPayload(action = 'opened', issueNumber = null, overrides = {}) {
const number = issueNumber || Math.floor(Math.random() * 100000);
return {
action,
sender: overrides.sender || { id: 1, username: REAL_REPO.owner },
issue: {
number,
title: overrides.title || `[测试] 工单 #${number}`,
body: overrides.body || '这是测试工单的描述内容',
state: action === 'closed' ? 'closed' : 'open',
labels: overrides.labels || [],
milestone: overrides.milestone || null,
assignee: overrides.assignee || null,
html_url: `${GITEA_BASE_URL}/${REAL_REPO.owner}/${REAL_REPO.repo}/issues/${number}`,
...overrides.issue
},
repository: {
name: REAL_REPO.repo,
owner: { username: REAL_REPO.owner },
...overrides.repository
},
comment: overrides.comment || null
};
}
function createJiraPayload(event = 'jira:issue_created', issueKey = null, overrides = {}) {
const key = issueKey || `${JIRA_PROJECT.key}-${Math.floor(Math.random() * 10000)}`;
return {
webhookEvent: event,
user: overrides.user || {
accountId: 'test-user-123',
displayName: 'Test User',
name: 'testuser'
},
issue: {
key,
id: Math.floor(Math.random() * 100000),
fields: {
summary: overrides.summary || `[测试] Jira 工单 ${key}`,
description: overrides.description || '这是从 Jira 创建的测试工单',
project: { key: JIRA_PROJECT.key, id: JIRA_PROJECT.id },
issuetype: { id: '10001', name: '故事' },
priority: { id: '3', name: 'Medium' },
status: {
name: '待办',
statusCategory: { key: 'new' }
},
assignee: overrides.assignee || null,
customfield_10105: overrides.sprint || null,
...overrides.fields
}
},
comment: overrides.comment || null,
changelog: overrides.changelog || null
};
}
async function test(category, name, expectations, fn) {
totalTests++;
const testId = `${category}.${totalTests}`;
log(colors.cyan, `\n${testId} ${name}`);
if (expectations) {
log(colors.gray, ' 预期结果:');
if (expectations.gitea) {
log(colors.gray, ` 📝 Gitea: ${expectations.gitea}`);
}
if (expectations.jira) {
log(colors.gray, ` 📋 Jira: ${expectations.jira}`);
}
if (expectations.logs) {
log(colors.gray, ` 📄 日志: ${expectations.logs}`);
}
}
try {
await fn();
passedTests++;
log(colors.green, ` ✓ 通过`);
testResults.push({ id: testId, name, status: 'PASS' });
return true;
} catch (error) {
failedTests++;
log(colors.red, ` ✗ 失败: ${error.message}`);
testResults.push({ id: testId, name, status: 'FAIL', error: error.message });
return false;
}
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ==================== 测试用例 ====================
async function runTests() {
log(colors.magenta, '\n' + '='.repeat(60));
log(colors.magenta, ' 🧪 Gitea-Jira 双向同步综合测试');
log(colors.magenta, '='.repeat(60));
log(colors.blue, `\n配置信息:`);
log(colors.cyan, ` 仓库: ${REAL_REPO.fullName}`);
log(colors.cyan, ` Jira 项目: ${JIRA_PROJECT.key}`);
log(colors.cyan, ` Webhook URL: ${BASE_URL}`);
// ========== 1. 安全性测试 ==========
log(colors.yellow, '\n\n📌 1. 安全性测试');
await test('SEC', '无签名请求被拒绝', {
logs: '应输出 "Request missing signature header"'
}, async () => {
const payload = createGiteaPayload('opened');
const result = await sendGiteaWebhook(payload, null, false);
assert(result.status === 401, `Expected 401, got ${result.status}`);
});
await test('SEC', '错误签名被拒绝', {
logs: '应输出 "Invalid signature detected"'
}, async () => {
const payload = createGiteaPayload('opened');
const result = await sendGiteaWebhook(payload, 'wrong_signature');
assert(result.status === 401, `Expected 401, got ${result.status}`);
});
await test('SEC', '缺失必要字段的payload被拒绝', {
logs: '应输出 "Invalid payload structure"'
}, async () => {
const payload = { action: 'opened' }; // 缺少 issue 和 repository
const sig = createSignature(payload);
const result = await sendGiteaWebhook(payload, sig);
assert(result.status === 400, `Expected 400, got ${result.status}`);
});
// ========== 2. Gitea -> Jira 基础功能测试 ==========
log(colors.yellow, '\n\n📌 2. Gitea → Jira 基础同步测试');
await test('G2J', '创建工单(无标签)', {
gitea: '工单 #XXX 已创建',
jira: `${JIRA_PROJECT.key} 项目创建工单,标题、描述同步,添加来源评论`,
logs: `[${REAL_REPO.fullName}] [GITEA->JIRA] Created ${JIRA_PROJECT.key}-XXX from #XXX`
}, async () => {
const payload = createGiteaPayload('opened', null, {
title: '[测试] 基础工单创建'
});
const result = await sendGiteaWebhook(payload);
assert(result.success && result.status === 200, 'Webhook should succeed');
await sleep(1000);
});
await test('G2J', '创建工单(带优先级标签)', {
gitea: '工单带 testhigh 标签',
jira: `创建 High 优先级工单`,
logs: `日志显示工单创建成功`
}, async () => {
const payload = createGiteaPayload('opened', null, {
title: '[测试] 高优先级工单',
labels: [{ name: 'testhigh' }]
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('G2J', '创建工单(带类型标签)', {
gitea: '工单带 testbug 标签',
jira: `创建 Bug 类型工单`,
logs: `日志显示工单创建成功`
}, async () => {
const payload = createGiteaPayload('opened', null, {
title: '[测试] Bug 类型工单',
labels: [{ name: 'testbug' }]
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('G2J', '创建工单(带里程碑)', {
gitea: '工单关联 v1.0.0 里程碑',
jira: `工单分配到 Sprint 37`,
logs: `日志显示工单创建成功`
}, async () => {
const payload = createGiteaPayload('opened', null, {
title: '[测试] 带里程碑的工单',
milestone: { title: 'v1.0.0' }
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
// ========== 3. Gitea -> Jira 状态同步测试 ==========
log(colors.yellow, '\n\n📌 3. Gitea → Jira 状态同步测试');
const issueNum = Math.floor(Math.random() * 100000);
await test('G2J', '创建后关闭工单', {
gitea: `关闭工单 #${issueNum}`,
jira: `对应 Jira 工单状态变为"完成"`,
logs: `[${REAL_REPO.fullName}] [GITEA->JIRA] Closed ${JIRA_PROJECT.key}-XXX`
}, async () => {
// 先创建
let payload = createGiteaPayload('opened', issueNum, {
title: '[测试] 状态同步工单'
});
await sendGiteaWebhook(payload);
await sleep(2000);
// 再关闭
payload = createGiteaPayload('closed', issueNum);
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Close webhook should succeed');
await sleep(1000);
});
await test('G2J', '重新打开工单', {
gitea: `重新打开工单 #${issueNum}`,
jira: `对应 Jira 工单状态变为"处理中"`,
logs: `[${REAL_REPO.fullName}] [GITEA->JIRA] Reopened ${JIRA_PROJECT.key}-XXX`
}, async () => {
const payload = createGiteaPayload('reopened', issueNum);
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Reopen webhook should succeed');
await sleep(1000);
});
// ========== 4. Jira -> Gitea 同步测试 ==========
log(colors.yellow, '\n\n📌 4. Jira → Gitea 反向同步测试');
await test('J2G', 'Jira 创建工单(需要手动创建或模拟)', {
jira: `${JIRA_PROJECT.key} 项目手动创建工单`,
gitea: `${REAL_REPO.fullName} 自动创建对应工单,带来源评论`,
logs: `[${REAL_REPO.fullName}] [JIRA->GITEA] Created #XXX from ${JIRA_PROJECT.key}-XXX`
}, async () => {
const payload = createJiraPayload('jira:issue_created');
const result = await sendJiraWebhook(payload);
assert(result.success, 'Jira webhook should succeed');
await sleep(1000);
});
await test('J2G', 'Jira 修改优先级', {
jira: `将工单优先级改为 High`,
gitea: `对应 Gitea 工单标签更新为 testhigh`,
logs: `[${REAL_REPO.fullName}] [JIRA->GITEA] Updated #XXX`
}, async () => {
const payload = createJiraPayload('jira:issue_updated', null, {
changelog: {
items: [{
field: 'priority',
fromString: 'Medium',
toString: 'High'
}]
},
fields: {
priority: { id: '2', name: 'High' }
}
});
const result = await sendJiraWebhook(payload);
assert(result.success, 'Priority change webhook should succeed');
await sleep(1000);
});
await test('J2G', 'Jira 修改类型', {
jira: `将工单类型改为 Bug`,
gitea: `对应 Gitea 工单标签更新为 testbug`,
logs: `[${REAL_REPO.fullName}] [JIRA->GITEA] Updated #XXX`
}, async () => {
const payload = createJiraPayload('jira:issue_updated', null, {
changelog: {
items: [{
field: 'issuetype',
fromString: '故事',
toString: 'Bug'
}]
},
fields: {
issuetype: { id: '10004', name: 'Bug' }
}
});
const result = await sendJiraWebhook(payload);
assert(result.success, 'Type change webhook should succeed');
await sleep(1000);
});
await test('J2G', 'Jira 修改 Sprint', {
jira: `将工单加入 Sprint 37`,
gitea: `对应 Gitea 工单里程碑设置为 v1.0.0`,
logs: `[${REAL_REPO.fullName}] [JIRA->GITEA] Updated #XXX`
}, async () => {
const payload = createJiraPayload('jira:issue_updated', null, {
changelog: {
items: [{
field: 'Sprint',
fromString: null,
toString: 'issueBot 1.0.0'
}]
},
fields: {
customfield_10105: [
'com.atlassian.greenhopper.service.sprint.Sprint@123[id=37,name=issueBot 1.0.0,state=ACTIVE]'
]
}
});
const result = await sendJiraWebhook(payload);
assert(result.success, 'Sprint change webhook should succeed');
await sleep(1000);
});
// ========== 5. 边界情况测试 ==========
log(colors.yellow, '\n\n📌 5. 边界情况测试');
await test('EDGE', '标题包含#不同步标记', {
gitea: '创建标题包含 #不同步 的工单',
jira: `不创建 Jira 工单`,
logs: `无同步日志`
}, async () => {
const payload = createGiteaPayload('opened', null, {
title: '测试工单 #不同步 测试'
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook accepted');
await sleep(500);
});
await test('EDGE', '超长标题500字符', {
gitea: '创建超长标题工单',
jira: `创建工单,标题可能被截断`,
logs: `工单创建成功`
}, async () => {
const longTitle = '[测试] ' + 'A'.repeat(500);
const payload = createGiteaPayload('opened', null, {
title: longTitle
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('EDGE', '超长描述5000字符', {
gitea: '创建超长描述工单',
jira: `创建工单,描述完整同步`,
logs: `工单创建成功`
}, async () => {
const longBody = '测试内容\n'.repeat(500);
const payload = createGiteaPayload('opened', null, {
body: longBody
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('EDGE', '空描述工单', {
gitea: '创建无描述工单',
jira: `创建工单,描述为空或"No description"`,
logs: `工单创建成功`
}, async () => {
const payload = createGiteaPayload('opened', null, {
body: ''
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('EDGE', '多个标签同时存在', {
gitea: '工单带多个标签(优先级+类型+其他)',
jira: `创建工单,正确映射优先级和类型,其他标签忽略`,
logs: `工单创建成功`
}, async () => {
const payload = createGiteaPayload('opened', null, {
labels: [
{ name: 'testhighest' },
{ name: 'testbug' },
{ name: 'enhancement' }
]
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('EDGE', '未配置的仓库', {
gitea: '向未配置的仓库发送 webhook',
jira: `不创建 Jira 工单`,
logs: `[unknown/repo] Repository not configured`
}, async () => {
const payload = createGiteaPayload('opened', null, {
repository: {
name: 'unknown-repo',
owner: { username: 'unknown-user' }
}
});
const result = await sendGiteaWebhook(payload);
assert(result.status === 500, 'Should return error');
});
// ========== 6. 并发和压力测试 ==========
log(colors.yellow, '\n\n📌 6. 并发和压力测试');
await test('STRESS', '连续快速创建10个工单', {
gitea: '快速创建10个工单',
jira: `创建10个对应工单`,
logs: `应该全部成功,但可能触发熔断器`
}, async () => {
const promises = [];
for (let i = 0; i < 10; i++) {
const payload = createGiteaPayload('opened', null, {
title: `[压力测试] 工单 ${i + 1}/10`
});
promises.push(sendGiteaWebhook(payload));
}
const results = await Promise.all(promises);
const successCount = results.filter(r => r.success).length;
log(colors.cyan, ` 成功: ${successCount}/10`);
assert(successCount >= 8, `Expected >=8 success, got ${successCount}`);
await sleep(2000);
});
await test('STRESS', '并发修改同一工单', {
gitea: '对同一工单发送多个修改请求',
jira: `工单更新成功,锁机制防止竞态`,
logs: `可能看到锁定和重试日志`
}, async () => {
const issueNum = Math.floor(Math.random() * 100000);
// 先创建
await sendGiteaWebhook(createGiteaPayload('opened', issueNum));
await sleep(1000);
// 并发修改
const promises = [];
for (let i = 0; i < 5; i++) {
const payload = createGiteaPayload('edited', issueNum, {
title: `[并发测试] 版本 ${i + 1}`
});
promises.push(sendGiteaWebhook(payload));
}
const results = await Promise.all(promises);
const successCount = results.filter(r => r.success).length;
assert(successCount >= 4, `Expected >=4 success, got ${successCount}`);
await sleep(1000);
});
// ========== 7. 机器人防死循环测试 ==========
log(colors.yellow, '\n\n📌 7. 机器人防死循环测试');
await test('BOT', 'Gitea 机器人操作被忽略', {
gitea: '机器人账号创建工单',
jira: `不创建 Jira 工单`,
logs: `无同步日志(被静默忽略)`
}, async () => {
const payload = createGiteaPayload('opened', null, {
sender: {
id: parseInt(process.env.GITEA_BOT_ID || '0'),
username: process.env.GITEA_BOT_NAME || 'issuebot'
}
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook accepted but ignored');
await sleep(500);
});
await test('BOT', 'Jira 机器人操作被忽略', {
jira: '机器人账号创建工单',
gitea: `不创建 Gitea 工单`,
logs: `无同步日志(被静默忽略)`
}, async () => {
const payload = createJiraPayload('jira:issue_created', null, {
user: {
accountId: process.env.JIRA_BOT_ID || 'bot-id',
displayName: 'Issue Bot',
name: process.env.JIRA_BOT_NAME || 'issuebot'
}
});
const result = await sendJiraWebhook(payload);
assert(result.success, 'Webhook accepted but ignored');
await sleep(500);
});
// ========== 测试总结 ==========
log(colors.magenta, '\n\n' + '='.repeat(60));
log(colors.magenta, ' 📊 测试结果汇总');
log(colors.magenta, '='.repeat(60));
const passRate = ((passedTests / totalTests) * 100).toFixed(1);
log(colors.cyan, `\n总计: ${totalTests} 个测试`);
log(colors.green, `✓ 通过: ${passedTests} (${passRate}%)`);
log(colors.red, `✗ 失败: ${failedTests} (${(100 - passRate).toFixed(1)}%)`);
if (failedTests > 0) {
log(colors.red, '\n失败的测试:');
testResults
.filter(r => r.status === 'FAIL')
.forEach(r => {
log(colors.red, ` ${r.id} ${r.name}: ${r.error}`);
});
}
log(colors.blue, `\n💡 提示:`);
log(colors.cyan, ` 1. 检查 logs/sync-${new Date().toISOString().split('T')[0]}.log 查看详细日志`);
log(colors.cyan, ` 2. 手动验证 Jira 项目 ${JIRA_PROJECT.key} 和 Gitea 仓库 ${REAL_REPO.fullName}`);
log(colors.cyan, ` 3. Jira→Gitea 测试需要确保工单已建立映射关系`);
log(colors.cyan, ` 4. 压力测试可能触发熔断器10秒内>20请求`);
log(colors.magenta, '\n' + '='.repeat(60) + '\n');
process.exit(failedTests > 0 ? 1 : 0);
}
// 运行测试
runTests().catch(error => {
log(colors.red, `\n测试执行出错: ${error.message}`);
process.exit(1);
});