diff --git a/index.js b/index.js index 6f70472..e5b0c68 100644 --- a/index.js +++ b/index.js @@ -102,7 +102,7 @@ app.post('/hooks/jira', rateLimiter, async (c) => { const body = await c.req.json(); logger.info(`[JIRA HOOK] Received request`, { event: body?.webhookEvent }); - // Jira Webhook通常没有签名头,依赖IP白名单或URL secret参数,此处仅校验结构 + //Jira Webhook通常没有签名头,依赖IP白名单或URL secret参数,此处仅校验结构 if (!body || !body.webhookEvent) { logger.warn(`[JIRA HOOK] Invalid payload: missing webhookEvent`); return c.text('Invalid Jira payload', 400); diff --git a/src/config/env.js b/src/config/env.js index 24bbd65..33e6dab 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -4,7 +4,7 @@ const config = { app: { port: process.env.PORT || 3000, dbPath: process.env.DB_FILE_PATH || './sync.sqlite', - rate : process.env.RATE_LIMIT_WINDOW || 10000, + 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 diff --git a/src/config/mappings.js b/src/config/mappings.js index 6d4023d..84dd33e 100644 --- a/src/config/mappings.js +++ b/src/config/mappings.js @@ -1,7 +1,7 @@ const fs = require('fs'); const path = require('path'); -// 读取配置文件路径 +//读取配置文件路径 const configPath = path.join(__dirname, '../../mappings.json'); let mappingsConfig = null; @@ -14,14 +14,14 @@ 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}`); @@ -86,11 +86,11 @@ const defaultMappings = new Proxy({}, { 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, @@ -117,7 +117,7 @@ function getConfiguredRepos() { */ function getRepoByJiraProject(jiraProjectKey) { const config = loadMappings(); - + for (const [repoKey, repoConfig] of Object.entries(config.repositories)) { if (repoConfig.jira && repoConfig.jira.projectKey === jiraProjectKey) { return { diff --git a/src/logic/converter.js b/src/logic/converter.js index 1232e1e..38e7592 100644 --- a/src/logic/converter.js +++ b/src/logic/converter.js @@ -5,11 +5,11 @@ const j2m = require('j2m'); //返回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) { @@ -21,7 +21,7 @@ function buildJiraFields(title, body, labels, milestone, repoConfig) { if (priorities[name]) priorityId = priorities[name]; } } - + //如果没有找到匹配的类型标签,检查是否使用默认类型 if (!typeFound) { if (jira.defaultType) { @@ -32,13 +32,13 @@ function buildJiraFields(title, body, labels, milestone, repoConfig) { } } - let sprintId = null; - + let sprintId = null; + if (milestone && sprints[milestone.title]) { sprintId = sprints[milestone.title]; } - // 如果body为空,给默认值,否则将Markdown转换为Jira Wiki Markup + //如果body为空,给默认值,否则将Markdown转换为Jira Wiki Markup let description = "No description"; if (body) { description = j2m.toJ(body); diff --git a/src/logic/jiraSyncManager.js b/src/logic/jiraSyncManager.js index 5219a61..777cad9 100644 --- a/src/logic/jiraSyncManager.js +++ b/src/logic/jiraSyncManager.js @@ -53,26 +53,26 @@ function findLabelByTypeName(typeName, typesMap) { //处理Java对象toString格式: "com.atlassian...Sprint@xxx[id=37,name=xxx,...]" function parseSprintId(sprintData) { if (!sprintData) return null; - - // 如果是数组,取最后一个(当前活动Sprint) + + //如果是数组,取最后一个(当前活动Sprint) if (Array.isArray(sprintData)) { if (sprintData.length === 0) return null; return parseSprintId(sprintData[sprintData.length - 1]); } - - // 如果已经是对象,直接返回id + + //如果已经是对象,直接返回id if (typeof sprintData === 'object' && sprintData.id) { return parseInt(sprintData.id); } - - // 如果是字符串(Java对象toString),用正则提取id + + //如果是字符串(Java对象toString),用正则提取id if (typeof sprintData === 'string') { const match = sprintData.match(/\bid=(\d+)/); if (match) { return parseInt(match[1]); } } - + return null; } @@ -95,16 +95,16 @@ async function handleJiraHook(payload) { 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) { @@ -115,28 +115,28 @@ async function handleJiraHook(payload) { //查找映射关系 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问题类型是否配置 @@ -154,30 +154,30 @@ async function handleJiraHook(payload) { 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); @@ -194,7 +194,7 @@ async function handleJiraHook(payload) { if (typeLabel) { labels.push(typeLabel); } - + //添加优先级标签 if (issue.fields.priority) { const priorityLabel = findGiteaLabel(issue.fields.priority.id, repoInfo.config.priorities); @@ -202,7 +202,7 @@ async function handleJiraHook(payload) { labels.push(priorityLabel); } } - + //如果有标签则设置 if (labels.length > 0) { await giteaService.replaceLabels(owner, repo, giteaIssue.number, labels); @@ -214,28 +214,28 @@ async function handleJiraHook(payload) { //通过评论记录来源链接 const jiraIssueUrl = `${config.jira.baseUrl}/browse/${issue.key}`; const giteaWebUrl = config.gitea.baseUrl.replace(/\/api\/v1\/?$/, ''); - // 使用repoInfo中的owner和repo,确保使用当前配置的仓库名 + //使用repoInfo中的owner和repo,确保使用当前配置的仓库名 const giteaIssueUrl = `${giteaWebUrl}/${owner}/${repo}/issues/${giteaIssue.number}`; - - const successMsg = isResyncCommand + + const successMsg = isResyncCommand ? `手动同步:已补建Gitea工单 [#${giteaIssue.number}](${giteaIssueUrl})` : `Gitea来源: ${giteaIssueUrl}\n由工单机器人创建`; - + Promise.all([ //在Gitea工单上添加评论,格式与Gitea->Jira一致 - giteaService.addComment(owner, repo, giteaIssue.number, + 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`); @@ -251,36 +251,36 @@ async function handleJiraHook(payload) { 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时强制更新里程碑 - + 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; @@ -290,7 +290,7 @@ async function handleJiraHook(payload) { updateData.state = 'open'; } } - + //同步经办人 if (issue.fields.assignee) { updateData.assignees = [issue.fields.assignee.name]; @@ -357,18 +357,18 @@ async function handleJiraHook(payload) { 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); @@ -387,7 +387,7 @@ async function handleJiraHook(payload) { } 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); } @@ -402,13 +402,13 @@ async function handleJiraHook(payload) { //获取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); @@ -432,12 +432,12 @@ async function handleJiraHook(payload) { 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, + await jiraService.addComment(issue.key, `手动同步:工单已存在,已更新 [Gitea #${giteaId}](${giteaIssueUrl})` ).catch(err => logger.error('Comment write-back failed', err.message)); } diff --git a/src/logic/syncManager.js b/src/logic/syncManager.js index f25ed90..bb252c8 100644 --- a/src/logic/syncManager.js +++ b/src/logic/syncManager.js @@ -7,7 +7,7 @@ const logger = require('../utils/logger'); const config = require('../config/env'); const { checkCircuitBreaker } = require('../utils/circuitBreaker'); -// 处理Gitea Issue事件的主逻辑 +//处理Gitea Issue事件的主逻辑 const processingIds = new Set(); const LOCK_TIMEOUT = 10000; const RETRY_DELAY = 1500; @@ -31,13 +31,13 @@ function isGiteaBot(sender) { async function handleIssueEvent(payload, retryCount = 0) { const { action, issue, repository, comment, sender } = payload; - + //如果操作者是机器人,直接忽略 if (isGiteaBot(sender)) { return; } - // 验证payload完整性 + //验证payload完整性 if (!issue || !issue.number || !repository) { logger.error('Invalid payload: missing required fields'); return; @@ -47,10 +47,10 @@ async function handleIssueEvent(payload, retryCount = 0) { if (!checkCircuitBreaker()) { return; } - + //构建仓库标识 (owner/repo格式) const repoKey = `${repository.owner.username}/${repository.name}`; - + //获取该仓库的配置 const repoConfig = getRepoConfig(repoKey); if (!repoConfig) { @@ -58,7 +58,7 @@ async function handleIssueEvent(payload, retryCount = 0) { logger.error(errorMsg); throw new Error(errorMsg); } - + //检测是否为/resync const isResyncCommand = (action === 'created' && comment && comment.body.trim() === '/resync'); const giteaId = issue.number; @@ -101,7 +101,7 @@ async function handleIssueEvent(payload, retryCount = 0) { //只有已存在的工单才能进行更新/关闭/重开操作 if (mapping) { - + //处理关闭事件 if (action === 'closed') { if (transitions && transitions.close) { @@ -132,14 +132,14 @@ async function handleIssueEvent(payload, retryCount = 0) { 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) { @@ -147,7 +147,7 @@ async function handleIssueEvent(payload, retryCount = 0) { await jiraService.updateIssue(mapping.jira_key, { assignee: { name: jiraUser.name } }); } } - + if (transitions && transitions.in_progress) { await jiraService.transitionIssue(mapping.jira_key, transitions.in_progress); } @@ -165,7 +165,7 @@ async function handleIssueEvent(payload, retryCount = 0) { repository.name, giteaId ); - + if (fullIssue.assignees && fullIssue.assignees.length > 0) { const firstAssignee = fullIssue.assignees[0]; const jiraUser = await jiraService.findUser(firstAssignee.username); @@ -190,12 +190,12 @@ async function handleIssueEvent(payload, retryCount = 0) { issue.milestone, repoConfig ); - + //已有映射的工单,即使类型未配置也允许同步(只是不更新类型字段) //这样当类型从未配置变为已配置时,也能正常同步 if (jiraFields) { - // 处理指派人同步(resync 时) - // Gitea 支持多个指派人,Jira 只支持一个经办人,取第一个 + //处理指派人同步(resync 时) + //Gitea 支持多个指派人,Jira 只支持一个经办人,取第一个 if (isResyncCommand) { if (issue.assignees && issue.assignees.length > 0) { const firstAssignee = issue.assignees[0]; @@ -230,9 +230,9 @@ async function handleIssueEvent(payload, retryCount = 0) { //只有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; + return; } - + const jiraFields = buildJiraFields( issue.title, issue.body, @@ -240,7 +240,7 @@ async function handleIssueEvent(payload, retryCount = 0) { issue.milestone, repoConfig ); - + //类型未配置,跳过同步 if (!jiraFields) { logger.info(`[${repoKey}] [GITEA->JIRA] Skipped #${giteaId}: type not configured in mappings`); @@ -258,9 +258,9 @@ async function handleIssueEvent(payload, retryCount = 0) { 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}`); @@ -269,11 +269,11 @@ async function handleIssueEvent(payload, retryCount = 0) { 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 + + const successMsg = isResyncCommand ? `手动同步:已补建Jira工单 [${newIssue.key}](${config.jira.baseUrl}/browse/${newIssue.key})` : `Jira来源:[${newIssue.key}](${config.jira.baseUrl}/browse/${newIssue.key})\n由工单机器人创建`; @@ -290,12 +290,12 @@ async function handleIssueEvent(payload, retryCount = 0) { } catch (error) { logger.error(`[${repoKey}] [GITEA->JIRA] Failed to sync #${giteaId}: ${error.message}`); - + if (isResyncCommand) { await giteaService.addComment( - repository.owner.username, - repository.name, - giteaId, + repository.owner.username, + repository.name, + giteaId, `同步失败: ${error.message}` ); } diff --git a/src/routes/editor.js b/src/routes/editor.js index 460a981..19761b1 100644 --- a/src/routes/editor.js +++ b/src/routes/editor.js @@ -31,7 +31,7 @@ editor.get('/status', (c) => { 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); @@ -41,7 +41,7 @@ editor.get('/status', (c) => { const fatalMatches = content.match(/\[FATAL\]/g); fatalCount = fatalMatches ? fatalMatches.length : 0; } - + return c.json({ success: true, status: 'running', @@ -61,22 +61,22 @@ editor.get('/status', (c) => { 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, @@ -92,7 +92,7 @@ editor.get('/history', (c) => { }); } } - + return c.json({ success: true, history: history.reverse() //从旧到新排序 @@ -109,7 +109,7 @@ editor.get('/logs', (c) => { //获取今天的日志文件 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, @@ -117,12 +117,12 @@ editor.get('/logs', (c) => { 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`, @@ -139,12 +139,12 @@ 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); @@ -156,9 +156,9 @@ editor.post('/logs/clear', (c) => { editor.post('/control', async (c) => { try { const { action } = await c.req.json(); - + logger.info(`[Editor] Control action received: ${action}`); - + //注意:实际的重启需要外部进程管理器(如 PM2) //这里只是记录日志 if (action === 'restart') { @@ -168,7 +168,7 @@ editor.post('/control', async (c) => { message: '重启请求已记录,请使用 PM2 或其他进程管理器执行重启' }); } - + return c.json({ success: false, error: '不支持的操作' @@ -183,14 +183,14 @@ editor.post('/control', async (c) => { 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) { @@ -204,18 +204,18 @@ 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: '配置已保存,重启服务后生效' @@ -229,13 +229,13 @@ editor.post('/env', async (c) => { editor.get('/guide', (c) => { try { if (!fs.existsSync(README_PATH)) { - return c.json({ - success: false, + 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) { @@ -250,10 +250,10 @@ editor.get('/mappings', (c) => { 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); @@ -265,24 +265,24 @@ editor.get('/mappings', (c) => { 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) { @@ -295,26 +295,26 @@ editor.post('/mappings', async (c) => { 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) { @@ -327,38 +327,38 @@ editor.delete('/mappings/:repoName', async (c) => { 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) { @@ -408,7 +408,7 @@ editor.post('/save', async (c) => { 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}`; @@ -442,23 +442,23 @@ editor.post('/scan', async (c) => { //尝试获取流转 - 从不同状态的工单收集所有可能的流转 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 => + 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)) { @@ -466,7 +466,7 @@ editor.post('/scan', async (c) => { } }); result.transitions = Array.from(transitionsMap.values()); - + if (result.transitions.length > 0) { result.warning = `已从 ${sampleIssues.length} 个工单中扫描到 ${result.transitions.length} 个状态流转。注意:Jira的流转取决于工单当前状态,未被扫描的必须手动配置。`; } @@ -487,11 +487,11 @@ editor.post('/scan-sprint', async (c) => { 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')) { @@ -512,7 +512,7 @@ editor.post('/scan-sprint', async (c) => { //代理 Jira API 请求 editor.post('/proxy-jira', async (c) => { const { url, auth } = await c.req.json(); - + try { const response = await axios.get(url, { headers: { @@ -521,13 +521,13 @@ editor.post('/proxy-jira', async (c) => { }, 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 + return c.json({ + success: false, + error: e.response?.data?.errorMessages?.[0] || e.message }, 500); } }); diff --git a/src/services/jira.js b/src/services/jira.js index 7b9e691..66d0a84 100644 --- a/src/services/jira.js +++ b/src/services/jira.js @@ -17,7 +17,7 @@ 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; + return res.data; } catch (error) { logger.error('Jira create issue failed', error.response?.data || error.message); throw error; @@ -31,7 +31,7 @@ async function updateIssue(key, fields) { logger.info(`Jira updated ${key}`); } catch (error) { logger.error(`Jira update issue failed (${key})`, error.response?.data || error.message); - throw error; + throw error; } } @@ -44,7 +44,7 @@ async function transitionIssue(key, 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; + throw error; } } @@ -63,15 +63,15 @@ async function findUser(query) { if (!query) return null; try { const res = await jiraClient.get('/rest/api/2/user/search', { - params: { - username: query, + params: { + username: query, maxResults: 10 } }); if (res.data && res.data.length > 0) { - const exactMatch = res.data.find(u => - u.name === query || - u.key === query || + const exactMatch = res.data.find(u => + u.name === query || + u.key === query || u.emailAddress === query || u.displayName === query ); diff --git a/src/utils/circuitBreaker.js b/src/utils/circuitBreaker.js index 8ad801f..44dd29d 100644 --- a/src/utils/circuitBreaker.js +++ b/src/utils/circuitBreaker.js @@ -14,8 +14,8 @@ function checkCircuitBreaker() { } requestCount++; if (requestCount > config.app.maxRequests) { - const msg = `Circuit breaker triggered: Exceeded ${config.app.maxRequests} requests in ${config.app.rate/1000}s. Exiting...`; - + const msg = `Circuit breaker triggered: Exceeded ${config.app.maxRequests} requests in ${config.app.rate / 1000}s. Exiting...`; + //同步写入fatal日志 logger.fatal("============================"); logger.fatal(`${msg}`); diff --git a/src/utils/logger.js b/src/utils/logger.js index 7bab88c..b57962f 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -27,11 +27,11 @@ function getTimestamp() { 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`; } @@ -39,9 +39,9 @@ function getLogFileName() { 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); @@ -49,9 +49,9 @@ function writeLog(level, message, data = null) { logLine += ' ' + data; } } - + logLine += '\n'; - + //异步写入,不阻塞主流程 fs.appendFile(logFile, logLine, (err) => { if (err) { @@ -64,9 +64,9 @@ function writeLog(level, message, data = null) { 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); @@ -74,9 +74,9 @@ function writeLogSync(level, message, data = null) { logLine += ' ' + data; } } - + logLine += '\n'; - + try { fs.appendFileSync(logFile, logLine); } catch (err) { @@ -90,22 +90,22 @@ const logger = { 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); @@ -114,19 +114,19 @@ const logger = { 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}`);