init:taskbot 1.1.0
This commit is contained in:
56
src/config/env.js
Normal file
56
src/config/env.js
Normal 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
144
src/config/mappings.js
Normal 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
48
src/db/connection.js
Normal 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
33
src/db/issueMap.js
Normal 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
63
src/logic/converter.js
Normal 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 };
|
||||
452
src/logic/jiraSyncManager.js
Normal file
452
src/logic/jiraSyncManager.js
Normal 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
308
src/logic/syncManager.js
Normal 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
535
src/routes/editor.js
Normal 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
87
src/services/gitea.js
Normal 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
93
src/services/jira.js
Normal 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 };
|
||||
34
src/utils/circuitBreaker.js
Normal file
34
src/utils/circuitBreaker.js
Normal 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
149
src/utils/logger.js
Normal 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;
|
||||
196
src/utils/tests_created_by_claude/cleanup-test-issues.js
Normal file
196
src/utils/tests_created_by_claude/cleanup-test-issues.js
Normal 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);
|
||||
});
|
||||
614
src/utils/tests_created_by_claude/comprehensive-test.js
Normal file
614
src/utils/tests_created_by_claude/comprehensive-test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user