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 };