452 lines
19 KiB
JavaScript
452 lines
19 KiB
JavaScript
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 }; |