Files
gitea-jira-task-bot/src/logic/syncManager.js

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