308 lines
14 KiB
JavaScript
308 lines
14 KiB
JavaScript
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 }; |