init:taskbot 1.1.0

This commit is contained in:
2026-01-29 15:38:49 +08:00
commit 4dcb117601
25 changed files with 6070 additions and 0 deletions

63
src/logic/converter.js Normal file
View 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 };

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