Files
gitea-jira-task-bot/src/logic/jiraSyncManager.js
2026-01-29 15:38:49 +08:00

452 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };