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

42
.env_sample Normal file
View File

@@ -0,0 +1,42 @@
#.env file for TaskBot
#请完成这里的配置后配置好mappings.json文件再启动程序
#端口
PORT=3000
#飞书机器人Webhook地址用于接收相关通知
LARK_WEBHOOK_URL=
#Gitea相关配置注意后边要带/api/v1
GITEA_BASE_URL=https://git.langcore.net/api/v1
#gitea token参考文档生成https://docs.gitea.io/en-us/api-token/
GITEA_TOKEN=
#gitea webhook secret请把每一个需要操作的仓库的secret都配置成相同的值
GITEA_WEBHOOK_SECRET=
#jira
JIRA_BASE_URL=https://jira.langcore.cn
#鉴权2选1推荐使用 API Token不使用的方式留空即可
JIRA_PAT=
JIRA_USERNAME=
JIRA_PASSWORD=
#sqlite数据库文件路径
DB_FILE_PATH=./data/sync_database.sqlite
#熔断配置单位毫秒和请求数debug模式下熔断不会退出程序
RATE_LIMIT_WINDOW=10000
MAX_REQUESTS_PER_WINDOW=20
DEBUG_MODE=false
#Bot信息配置
GITEA_BOT_ID=12
GITEA_BOT_NAME=Task-Sync-bot
JIRA_BOT_ID=JIRAUSER11802
JIRA_BOT_NAME=syncbot
#保留日志天数
LOG_RETENTION_DAYS=30

367
README.md Normal file
View File

@@ -0,0 +1,367 @@
# Gitea-Jira工单同步系统
## 部署指南
### 第一步:环境准备
#### 1.1 系统要求
- Node.js版本: 14.0.0或更高
- npm版本: 6.0.0或更高
- SQLite: 3.0或更高
#### 1.2 依赖安装
```bash
cd /path/to/gitea-jira-sync
npm install
```
该命令将安装以下依赖:
- express (5.x): Web框架和HTTP服务器
- axios (1.13.x): HTTP客户端库
- better-sqlite3 (9.x): SQLite数据库驱动
- dotenv (17.x): 环境变量加载
- express-rate-limit (8.x): 速率限制中间件
- j2m (1.1.x): Markdown与Jira格式转换库
### 第二步:环境变量配置
#### 2.1 创建.env文件
```bash
cp .env.example .env
```
#### 2.2 配置SQLite数据库
```
SQLITE_PATH=./data/gitea_jira_sync.db
```
获取方法:
- SQLITE_PATH: SQLite数据库文件路径默认./data/gitea_jira_sync.db
注意: 数据库文件和表会自动创建,无需手动初始化
#### 2.3 配置Gitea信息
```
GITEA_URL=https://gitea.example.com
GITEA_TOKEN=your_gitea_api_token
GITEA_BOT_ID=your_bot_user_id
GITEA_BOT_NAME=your_bot_username
```
获取方法:
- GITEA_URL: Gitea服务器地址
- GITEA_TOKEN: 登录Gitea后访问设置->应用->创建新令牌api_token
- GITEA_BOT_ID: 登录机器人账号访问API /api/v1/user 获取id字段
- GITEA_BOT_NAME: 机器人的用户名
#### 2.4 配置Jira信息
```
JIRA_URL=https://jira.example.com
JIRA_USERNAME=your_jira_username
JIRA_PASSWORD=your_jira_password_or_pat
JIRA_PROJECT_ID_1=10000
JIRA_PROJECT_KEY_1=TEST
JIRA_BOT_ID=jira_bot_id
JIRA_BOT_NAME=jira_bot_name
```
获取方法:
- JIRA_URL: Jira服务器地址
- JIRA_USERNAME: Jira用户名
- JIRA_PASSWORD: Jira密码或个人访问令牌推荐使用PAT
- JIRA_PROJECT_ID_1: 访问/rest/api/2/project获取id
- JIRA_PROJECT_KEY_1: 项目的英文标识符如TEST、PROJ等
- JIRA_BOT_ID: 机器人账号的ID或Key
- JIRA_BOT_NAME: 机器人显示名称
#### 2.5 配置Webhook签名密钥
```
GITEA_WEBHOOK_SECRET=your_webhook_secret_key
JIRA_WEBHOOK_SECRET=your_jira_webhook_secret
```
#### 2.6 配置熔断器参数(可选)
```
CIRCUIT_BREAKER_LIMIT=20
CIRCUIT_BREAKER_WINDOW=10000
DEBUG_MODE=false
```
参数说明:
- CIRCUIT_BREAKER_LIMIT: 时间窗口内允许的最大请求数默认20
- CIRCUIT_BREAKER_WINDOW: 时间窗口大小单位毫秒默认10000ms=10秒
- DEBUG_MODE: 是否启用调试模式默认false启用后熔断器不会强制退出
### 第三步:映射配置
#### 3.1 编辑mappings.json
配置需要同步的仓库及其字段映射
目前已经开发了图形化配置工具无需再手动配置但是遇到特殊情况比如json损坏、扫不出流转状态和sprint还是要手动实测发生概率不大
#### 3.2 获取Jira映射ID
**获取Priority ID**:
```bash
curl -u username:password https://jira.example.com/rest/api/2/priority
```
返回JSON中查看id字段
**获取Issue Type ID**:
```bash
curl -u username:password https://jira.example.com/rest/api/2/issuetype
```
返回JSON中查看id字段
**获取Sprint ID**:
```bash
curl -u username:password https://jira.example.com/rest/api/2/issue/TEST-1
```
搜索customfield_10105字段格式[id=37,name=Sprint Name,...]
**获取Transition ID**:
```bash
curl -u username:password https://jira.example.com/rest/api/2/issue/TEST-1/transitions
```
查看transitions数组中的id字段
### 第四步:启动服务
#### 4.1 直接运行
```bash
node index.js
```
输出示例:
```
Server running on http://localhost:3000
Database initialized: issue_mapping table ready
Webhook endpoints ready:
POST /webhook/gitea
POST /webhook/jira
```
#### 4.2 后台运行使用pm2
```bash
npm install -g pm2
pm2 start index.js --name "gitea-jira-sync"
pm2 save
pm2 startup
```
#### 4.3 使用systemd服务Linux
创建/etc/systemd/system/gitea-jira-sync.service:
```ini
[Unit]
Description=Gitea-Jira Sync Service
After=network.target
[Service]
Type=simple
User=your_user
WorkingDirectory=/path/to/gitea-jira-sync
ExecStart=/usr/bin/node index.js
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
启动服务:
```bash
sudo systemctl start gitea-jira-sync
sudo systemctl enable gitea-jira-sync
```
### 第五步配置Webhook
#### 5.1 Gitea端配置
1. 进入仓库设置 -> 设置 -> Web Hooks -> Gitea
2. 点击"添加Webhook"
3. 填写以下信息:
- 推送地址: http://your_server:3000/webhook/gitea
- HTTP方法: POST
- POST内容类型: application/json
- 密钥: 与.env中GITEA_WEBHOOK_SECRET一致
- 触发事件: Issue和Comment必选
4. 测试Webhook确保连接正常
#### 5.2 Jira端配置
1. 访问Jira管理界面 -> 系统 -> Webhooks
2. 点击"创建webhook"
3. 填写以下信息:
- 名称: Gitea Sync Webhook
- URL: http://your_server:3000/webhook/jira
- 事件类型: jira:issue_created, jira:issue_updated, jira:issue_deleted
- 认证: Basic auth或Bearer token与.env配置一致
4. 点击"创建"
### 第六步:验证部署
#### 6.1 检查服务状态
```bash
curl http://localhost:3000/health
```
预期输出: 200状态码表示服务运行正常
#### 6.2 查看日志
```bash
tail -f logs/$(date +%Y-%m-%d).log
```
观察同步操作的日志记录
#### 6.3 测试同步
**测试Gitea->Jira**:
1. 在Gitea中新建工单
2. 观察logs中出现同步日志
3. 检查Jira中是否创建了对应问题
**测试Jira->Gitea**:
1. 在Jira中新建问题
2. 观察logs中出现同步日志
3. 检查Gitea中是否创建了对应工单
## 使用指南
### 场景一:新建工单自动同步
**Gitea新建工单**:
1. 进入Gitea仓库
2. 点击Issues -> 新建Issue
3. 填写标题、描述、指派人、标签(优先级、类型)、里程碑
4. 点击提交
5. 系统自动在Jira创建对应问题
6. Gitea工单中添加评论包含Jira问题链接
**Jira新建问题**:
1. 进入Jira项目
2. 点击创建按钮
3. 填写摘要、描述、经办人、优先级、问题类型、迭代
4. 点击创建
5. 系统自动在Gitea创建对应工单
6. Jira问题中添加评论包含Gitea工单链接
### 场景二:更新工单字段
**修改优先级**:
1. 在任何一端修改优先级
2. 系统自动更新对方平台的对应标签
3. 对应标签根据mappings.js中的优先级映射进行转换
**修改工单类型**:
1. 在任何一端修改工单类型
2. 系统自动更新对方平台的对应标签
3. 对应标签根据mappings.js中的类型映射进行转换
**修改迭代/里程碑**:
1. 在任何一端修改迭代或里程碑
2. 系统自动更新对方平台
3. 里程碑名称与Sprint ID根据mappings.js进行映射
**重开操作**:
1. 在Gitea关闭工单 -> Jira问题自动转至完成状态
2. 在Jira完成问题 -> Gitea工单自动关闭
3. 重开操作会将对方平台状态改为处理中/打开
### 场景三:跨平台手动同步
**使用/resync命令**:
1. 在Gitea或Jira工单评论中输入`/resync`
2. 系统会强制重新同步所有字段
3. 如果对方平台不存在对应工单,会自动创建
4. 如果已存在,会更新所有字段
**#不同步标记**:
1. 在工单标题中添加`#不同步`标记
2. 该工单不会自动同步到对方平台
3. 使用`/resync`命令可以强制同步带有`#不同步`标记的工单
### 场景四:类型过滤
**类型未配置的工单不同步**:
1. 如果工单的类型标签在mappings.json中没有配置对应关系
2. 且mappings.json中没有设置`defaultType`
3. 该工单不会被同步,日志会记录跳过原因
### 启用DEBUG模式
用于开发和故障诊断:
```bash
DEBUG_MODE=true node index.js
```
DEBUG模式下:
- 打印所有webhook payload
- 打印API调用详情
- 跳过熔断器强制退出
- 输出详细的转换过程
## 性能优化
### 1. 连接池配置
- axios客户端使用keep-alive连接
- 自动复用TCP连接减少握手开销
### 2. 数据库优化
- SQLite支持并发访问和连接优化
- 映射表创建了repo_key、gitea_id、jira_key索引加速查询
- 支持事务和完整的ACID特性
### 3. 异步处理
- 日志写入使用异步队列
- Webhook响应立即返回实际处理异步进行
### 4. 缓存策略
- 标签、优先级、工单类型信息在mappings.json中缓存
- 避免每次处理都请求Jira API
## 安全考虑
### 1. Webhook签名验证
- 所有webhook请求都经过HMAC签名验证
- 未通过验证的请求被直接拒绝
### 2. API认证
- Gitea使用token认证
- Jira支持Basic Auth和PAT认证
### 3. 敏感信息保护
- 环境变量不在代码中硬编码
- 日志不记录password和token字段
- SQLite数据库文件应该在安全位置存储
### 4. 防无限循环
- 机器人识别防止自己操作的无限循环
- 熔断机制防止恶意攻击导致的无限请求
- 内存锁防止重复处理同一工单
## 代码结构
```
src/
config/ - 配置文件
env.js - 环境变量加载
mappings.js - 字段映射配置
db/ - 数据库相关
connection.js - 数据库连接
issueMap.js - 映射表操作
logic/ - 业务逻辑
converter.js - 数据转换
syncManager.js - Gitea->Jira同步
jiraSyncManager.js - Jira->Gitea同步
services/ - API服务
gitea.js - Gitea API客户端
jira.js - Jira API客户端
utils/ - 工具函数
logger.js - 日志模块
circuitBreaker.js - 熔断器
index.js - 主程序
mappings.json -映射表
```

67
how-to-use.md Normal file
View File

@@ -0,0 +1,67 @@
### 场景一:新建工单自动同步
**Gitea新建工单**:
1. 进入Gitea仓库
2. 点击Issues -> 新建Issue
3. 填写标题、描述、指派人、标签(优先级、类型)、里程碑
4. 点击提交
5. 系统自动在Jira创建对应问题
6. Gitea工单中添加评论包含Jira问题链接
**Jira新建问题**:
1. 进入Jira项目
2. 点击创建按钮
3. 填写摘要、描述、经办人、优先级、问题类型、迭代
4. 点击创建
5. 系统自动在Gitea创建对应工单
6. Jira问题中添加评论包含Gitea工单链接
### 场景二:更新工单字段
**修改优先级**:
1. 在任何一端修改优先级
2. 系统自动更新对方平台的对应标签
3. 对应标签根据mappings.json中的优先级映射进行转换
**修改工单类型**:
1. 在任何一端修改工单类型
2. 系统自动更新对方平台的对应标签
3. 对应标签根据mappings.json中的类型映射进行转换
**修改迭代/里程碑**:
1. 在任何一端修改迭代或里程碑
2. 系统自动更新对方平台
3. 里程碑名称与Sprint ID根据mappings.json进行映射
**更新指派人/经办人**
1. 在任何一端更改指派人
2. 系统自动更新对方平台如果Gitea有多个指派人Jira经办人取第一个
### 场景三:跨平台手动同步
**使用/resync命令**:
1. 在Gitea或Jira工单评论中输入`/resync`
2. 系统会强制重新同步所有字段
3. 如果对方平台不存在对应工单,会自动创建
4. 如果已存在,会更新所有字段
**#不同步标记**:
1. 在工单标题中添加`#不同步`标记
2. 该工单不会自动同步到对方平台
3. 使用`/resync`命令可以强制同步带有`#不同步`标记的工单
### 场景四:类型过滤
**类型未配置的工单不同步**:
1. 如果工单的类型标签在mappings.json中没有配置对应关系
2. 或是Gitea创建工单时没有选择类型标签、Jira创建问题时选的问题类型在Gitea那边没有对应的标签
3. 该工单不会被同步,日志会记录跳过原因
4. 原本就没被同步/被过滤的工单,任何操作都不会触发创建事件,这样设定是为了保护以前的陈旧工单被挖坟的时候不会被重新同步
5. 需要强制同步未同步的工单,请使用`/resync`命令
**手动打标签**
1. 由于Gitea平台的类型标签和Jira问题类型并不是一一对应可能会出现多个类型标签对应同一个Jira问题的情况
2. 这会导致Jira那边创建问题对应了多个标签的时候机器人会不知道该选哪个标签此时会默认选第一个这就会出现Jira那边创建了一个开发任务虽然它是一个需求但是被打上新功能标签的问题
3. 为解决这个问题我为机器人引入了规则允许用户在Jira创建问题的时候在问题上带一个[flag][flag]xxxx这样机器人就会寻址到[类型/flag]标签,举个例子,某个开发任务可以写成“[新功能]我是一个新功能”在Gitea那边就会被正确地打上[类型/新功能]标签
4. 标题flag的优先级会高于设定的类型如果你不小心把类型选到了故障如果标题上有[新功能],还是以标题为准,如果两个都“不小心”写错了(是故意的还是不小心的?),以类型(多对一时是映射表里设定的第一个)为准
5. Gitea那边不会有上述特性因为通常不会出现多个Jira类型对应一个标签的情况不打标签非要写标题上你就是纯坏纯坏

156
index.js Normal file
View File

@@ -0,0 +1,156 @@
const { Hono } = require('hono');
const { serve } = require('@hono/node-server');
const { serveStatic } = require('@hono/node-server/serve-static');
const crypto = require('crypto');
const config = require('./src/config/env');
const { getConfiguredRepos } = require('./src/config/mappings');
const { handleIssueEvent } = require('./src/logic/syncManager');
const { handleJiraHook } = require('./src/logic/jiraSyncManager');
const editorRoutes = require('./src/routes/editor');
const logger = require('./src/utils/logger');
const app = new Hono();
const requestCounts = new Map();
const rateLimiter = async (c, next) => {
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
const now = Date.now();
const windowMs = 15 * 60 * 1000;
const maxRequests = 100;
if (!requestCounts.has(ip)) {
requestCounts.set(ip, []);
}
const requests = requestCounts.get(ip).filter(time => now - time < windowMs);
requests.push(now);
requestCounts.set(ip, requests);
if (requests.length > maxRequests) {
return c.text('Too many requests from this IP, please try again later.', 429);
}
await next();
};
//定期清理过期的请求记录
setInterval(() => {
const now = Date.now();
const windowMs = 15 * 60 * 1000;
for (const [ip, times] of requestCounts.entries()) {
const validTimes = times.filter(time => now - time < windowMs);
if (validTimes.length === 0) {
requestCounts.delete(ip);
} else {
requestCounts.set(ip, validTimes);
}
}
}, 5 * 60 * 1000);
//Gitea webhook处理入口
app.post('/hooks/gitea', rateLimiter, async (c) => {
try {
const signature = c.req.header('x-gitea-signature');
const event = c.req.header('x-gitea-event');
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
if (!config.gitea.secret) {
logger.error('GITEA_WEBHOOK_SECRET not configured!');
return c.text('Server configuration error', 500);
}
if (!signature) {
logger.security('Request missing signature header', { ip });
return c.text('Signature required', 401);
}
//获取原始请求体进行签名验证
const rawBody = await c.req.text();
const hmac = crypto.createHmac('sha256', config.gitea.secret);
const digest = hmac.update(rawBody).digest('hex');
if (digest !== signature) {
logger.security('Invalid signature detected', { ip });
return c.text('Invalid Signature', 401);
}
//解析JSON
const body = JSON.parse(rawBody);
//Payload结构验证
if (!body || !body.issue || !body.repository) {
logger.warn('Invalid payload structure', { ip });
return c.json({ error: 'Invalid payload structure' }, 400);
}
if (event === 'issues' || event === 'issue_comment') {
//异步处理不阻塞webhook返回
handleIssueEvent(body).catch(err => logger.error('Async handler error', err.message));
} else {
logger.info(`Ignored event type: ${event}`, { ip });
}
return c.json({ ok: true });
} catch (error) {
logger.error('Webhook Error', error.message);
return c.json({ error: 'Internal Server Error' }, 500);
}
});
//Jira webhook处理入口
app.post('/hooks/jira', rateLimiter, async (c) => {
try {
const body = await c.req.json();
logger.info(`[JIRA HOOK] Received request`, { event: body?.webhookEvent });
// Jira Webhook通常没有签名头依赖IP白名单或URL secret参数此处仅校验结构
if (!body || !body.webhookEvent) {
logger.warn(`[JIRA HOOK] Invalid payload: missing webhookEvent`);
return c.text('Invalid Jira payload', 400);
}
handleJiraHook(body).catch(err => logger.error('Jira Async handler error', err.message));
return c.text('OK');
} catch (error) {
logger.error('Jira Webhook Error', error.message);
return c.text('Internal Server Error', 500);
}
});
const configuredRepos = getConfiguredRepos();
//控制台首页
app.get('/', (c) => c.redirect('/dashboard'));
app.get('/dashboard', serveStatic({ path: './public/dashboard.html' }));
app.route('/api', editorRoutes);
app.route('/editor/api', editorRoutes);
app.use('/editor/*', serveStatic({
root: './public',
rewriteRequestPath: (path) => path.replace(/^\/editor/, '')
}));
app.use('/assets/*', serveStatic({
root: './public',
rewriteRequestPath: (path) => path.replace(/^\/assets/, '')
}));
logger.info(`Sync Bot running on port ${config.app.port}`);
logger.info(`Target Jira: ${config.jira.baseUrl}`);
logger.info(`Target Gitea: ${config.gitea.baseUrl}`);
logger.info(`Bot Identity - Gitea ID:${config.gitea.botId || 'N/A'}, Name:${config.gitea.botName || 'N/A'}`);
logger.info(`Bot Identity - Jira ID:${config.jira.botId || 'N/A'}, Name:${config.jira.botName || 'N/A'}`);
logger.info(`Configured repositories: ${configuredRepos.length}`);
configuredRepos.forEach(repo => logger.info(` - ${repo}`));
logger.info('Logs directory: logs/');
logger.info(`Dashboard: http://localhost:${config.app.port}/dashboard`);
serve({
fetch: app.fetch,
port: config.app.port
});
logger.info(`Server started successfully`);

100
mappings.json Normal file
View File

@@ -0,0 +1,100 @@
{
"comment": "映射表配置文件 - 配置此文件以启用特定仓库的同步功能。未在此文件中配置的仓库将不会进行同步。配置前,检查.env文件是否正确设置。此表已实现自动化配置通常不要手动编辑除非你知道自己在做什么。",
"guide": {
"description": "仓库手动配置指引",
"steps": [
"1.在环境变量(.env中设置好JIRA PROJECT ID和KEY",
"2.根据提示获得映射Gitea的变量是长啥样是啥样但是JIRA需要你去找各种ID",
"3.配置好启用的仓库,尽量不要用默认",
"4.映射配置不全时会使用defaultMappings中的默认值",
"5.SprintField通常是customfield_10105应该是Jira指定的不要随意更改"
],
"howToGetIds": {
"projectId": "访问 https://jira.langcore.cn/rest/api/2/project/{项目key} 的第一行 id 字段",
"priorities": "访问 https://jira.langcore.cn/rest/api/2/priority 查看 JSON 中的 id 和 name 字段。当前从highest到lowest分别是1到5",
"issueTypes": "访问 https://jira.langcore.cn/rest/api/2/issuetype 查看JSON中的id字段。当前bug是10004story是10001",
"sprints": "在已经加入了目标Sprint的任意Jira工单中访问 https://jira.langcore.cn/rest/api/2/issue/{工单Key},搜索 customfield_10105(或Sprint字段ID),查找[id=*,rapidViewId=*,name=...]格式中的id值",
"transitions": "访问 https://jira.langcore.cn/rest/api/2/issue/{工单Key}/transitions 查看JSON中的transitions数组。当前的三个id是 待办11 处理中21 完成31"
}
},
"repositories": {
"loren/SyncbotPlayground": {
"jira": {
"projectId": "10600",
"projectKey": "TEST",
"sprintField": "customfield_10105"
},
"priorities": {
"优先级/最高": "1",
"优先级/高": "2",
"优先级/中": "3",
"优先级/低": "4",
"优先级/最低": "5"
},
"types": {
"类型/故事": "10100",
"类型/Bug": "10004",
"类型/任务": "10100"
},
"transitions": {
"close": "31",
"reopen": "21"
},
"sprints": {
"v1.0.0": 36
}
},
"langcore-develop-team/ltm": {
"jira": {
"projectId": "10001",
"projectKey": "LTM",
"sprintField": "customfield_10105"
},
"priorities": {
"优先级/紧急": "1",
"优先级/高": "2",
"优先级/中": "3",
"优先级/低": "4",
"优先级/极低": "5"
},
"types": {
"类型/新功能": "10002",
"类型/UI变更": "10002",
"类型/增强": "10002",
"类型/安全": "10002",
"类型/文档": "10002",
"类型/测试": "10002",
"类型/需求": "10002",
"类型/Bug": "10004"
},
"transitions": {},
"sprints": {
"v1.16.0": 39,
"v1.15.0": 36,
"v1.14.0": 35
}
}
},
"defaultMappings": {
"comment": "默认映射配置 - 当仓库未配置特定映射时使用这些默认值。注意通常不要使用默认Jira项目配置建议每个仓库都明确配置因为这会导致工单创建到同一个项目",
"priorities": {
"testhighest": "1",
"testhigh": "2",
"testmid": "3",
"testlow": "4",
"testlowest": "5"
},
"types": {
"testbug": "10004",
"teststory": "10001"
},
"sprints": {
"v1.0.0": 37
},
"transitions": {
"close": "31",
"reopen": "21",
"in_progress": "21"
}
}
}

1479
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "gitea-jira-sync",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@hono/node-server": "^1.19.9",
"axios": "^1.13.2",
"better-sqlite3": "^12.6.2",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.7",
"j2m": "^1.1.0"
}
}

400
public/dashboard-app.js Normal file
View File

@@ -0,0 +1,400 @@
//标签页切换
function switchTab(tab) {
//更新侧边栏按钮状态
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('bg-indigo-600', 'text-white');
btn.classList.add('hover:bg-slate-800', 'text-slate-400', 'hover:text-white');
});
const activeBtn = document.getElementById(`tab-${tab}`);
if (activeBtn) {
activeBtn.classList.add('bg-indigo-600', 'text-white');
activeBtn.classList.remove('hover:bg-slate-800', 'text-slate-400', 'hover:text-white');
}
//切换内容区
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
const activeContent = document.getElementById(`content-${tab}`);
if (activeContent) {
activeContent.classList.remove('hidden');
}
//如果切换到日志页,开始实时加载
if (tab === 'logs') {
startLogStreaming();
} else {
stopLogStreaming();
}
//如果切换到设置页,加载 .env 文件
if (tab === 'settings') {
loadEnvFile();
}
//如果切换到使用指南页,加载 README
if (tab === 'guide') {
loadGuide();
}
}
//日志流控制
let logInterval = null;
let lastLogSize = 0;
async function startLogStreaming() {
//立即加载一次
await loadLogs();
//每2秒刷新一次
logInterval = setInterval(loadLogs, 2000);
}
function stopLogStreaming() {
if (logInterval) {
clearInterval(logInterval);
logInterval = null;
}
}
async function loadLogs() {
try {
const res = await fetch('/api/logs');
const data = await res.json();
if (data.success) {
const logViewer = document.getElementById('log-viewer');
document.getElementById('log-filename').textContent = data.filename || 'sync_service.log';
if (data.logs && data.logs.length > 0) {
//只在日志有变化时更新
const newContent = data.logs.map((log, index) =>
`<div class="log-line border-l-2 border-transparent hover:border-slate-700 hover:bg-slate-800/50 pl-2 py-0.5">
<span class="opacity-50 select-none mr-2">${index + 1}</span>${escapeHtml(log)}
</div>`
).join('');
if (logViewer.innerHTML !== newContent) {
logViewer.innerHTML = newContent;
//自动滚动到底部
logViewer.scrollTop = logViewer.scrollHeight;
}
} else {
logViewer.innerHTML = '<div class="text-slate-500 text-center py-8">暂无日志</div>';
}
}
} catch (e) {
console.error('加载日志失败:', e);
}
}
//HTML 转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
//加载仪表盘数据
async function loadDashboardData() {
try {
const res = await fetch('/api/status');
const data = await res.json();
if (data.success) {
//更新统计数据
document.getElementById('today-syncs').textContent = data.todaySyncs || '--';
document.getElementById('repo-count').textContent = data.repoCount || '--';
document.getElementById('error-count').textContent = (data.errorCount + data.fatalCount) || '--';
document.getElementById('uptime').textContent = data.uptime ? `系统运行时间: ${data.uptime}` : '加载中...';
//更新服务状态
updateServiceStatus(data.status);
}
//加载历史记录
loadHistory();
} catch (e) {
console.error('加载仪表盘数据失败:', e);
}
}
async function loadHistory() {
try {
const res = await fetch('/api/history');
const data = await res.json();
if (data.success && data.history) {
const tbody = document.getElementById('history-table');
if (data.history.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">暂无历史记录</td></tr>';
return;
}
tbody.innerHTML = data.history.map(day => `
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 text-sm text-slate-900">${day.date}</td>
<td class="px-4 py-3 text-sm text-slate-900 text-right">${day.syncs}</td>
<td class="px-4 py-3 text-sm ${day.errors > 0 ? 'text-rose-600 font-medium' : 'text-slate-900'} text-right">${day.errors}</td>
<td class="px-4 py-3 text-sm ${day.fatals > 0 ? 'text-rose-700 font-bold' : 'text-slate-900'} text-right">${day.fatals}</td>
</tr>
`).join('');
}
} catch (e) {
console.error('加载历史数据失败:', e);
const tbody = document.getElementById('history-table');
tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-rose-500">加载失败</td></tr>';
}
}
function updateServiceStatus(status) {
const badge = document.getElementById('status-badge');
const statusText = document.getElementById('service-status');
if (status === 'running') {
badge.className = 'px-3 py-1 rounded-full text-xs font-medium flex items-center border bg-emerald-50 text-emerald-600 border-emerald-200';
badge.innerHTML = `
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
运行中
`;
statusText.textContent = '运行中';
} else {
badge.className = 'px-3 py-1 rounded-full text-xs font-medium flex items-center border bg-slate-100 text-slate-600 border-slate-200';
badge.innerHTML = `
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
已停止
`;
statusText.textContent = '已停止';
}
}
//控制机器人
async function controlBot(action) {
try {
const res = await fetch('/api/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
const data = await res.json();
if (data.success) {
alert(`操作成功: ${data.message || action}`);
loadDashboardData();
} else {
alert(`操作失败: ${data.error}`);
}
} catch (e) {
alert(`操作失败: ${e.message}`);
}
}
//清空日志
async function clearLogs() {
if (!confirm('确定要清空日志吗?此操作不可恢复。')) {
return;
}
try {
const res = await fetch('/api/logs/clear', {
method: 'POST'
});
const data = await res.json();
if (data.success) {
alert('日志已清空');
if (document.getElementById('content-logs').classList.contains('hidden') === false) {
loadLogs();
}
} else {
alert(`清空失败: ${data.error}`);
}
} catch (e) {
alert(`清空失败: ${e.message}`);
}
}
//刷新状态
function refreshStatus() {
loadDashboardData();
}
async function loadEnvFile() {
const container = document.getElementById('envEditor');
try {
const res = await fetch('/api/env');
const data = await res.json();
if (data.success) {
const envContent = data.content;
const envItems = parseEnvContent(envContent);
renderEnvForm(envItems);
} else {
container.innerHTML = `<div class="text-center py-8 text-rose-400">${data.error}</div>`;
}
} catch (e) {
container.innerHTML = `<div class="text-center py-8 text-rose-400">加载失败: ${e.message}</div>`;
}
}
function parseEnvContent(content) {
const lines = content.split('\n');
const items = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) {
items.push({ type: 'comment', value: line });
} else if (trimmed.includes('=')) {
const equalIndex = trimmed.indexOf('=');
const key = trimmed.substring(0, equalIndex).trim();
const value = trimmed.substring(equalIndex + 1).trim();
items.push({ type: 'var', key, value });
} else {
items.push({ type: 'comment', value: line });
}
}
return items;
}
function renderEnvForm(items) {
const container = document.getElementById('envEditor');
container.innerHTML = items.map((item, index) => {
if (item.type === 'comment') {
return `<div class="text-slate-400 text-xs font-mono py-1">${escapeHtml(item.value)}</div>`;
} else {
const isSecret = item.key.toLowerCase().includes('token') ||
item.key.toLowerCase().includes('secret') ||
item.key.toLowerCase().includes('password') ||
item.key.toLowerCase().includes('pat');
return `
<div class="flex items-center gap-3 bg-slate-50 hover:bg-slate-100 p-3 rounded border border-slate-200 transition-colors">
<label class="text-slate-700 font-mono text-sm flex-shrink-0 w-48 font-medium">${escapeHtml(item.key)}</label>
<span class="text-slate-400">=</span>
<input type="${isSecret ? 'password' : 'text'}"
data-key="${escapeHtml(item.key)}"
value="${escapeHtml(item.value)}"
class="flex-1 bg-white border border-slate-300 rounded px-3 py-2 text-slate-900 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="未设置">
</div>
`;
}
}).join('');
}
async function saveEnvFile() {
const btn = document.getElementById('saveEnvBtn');
if (!confirm('确定要保存配置吗?保存后需要重启服务才能生效。')) {
return;
}
btn.disabled = true;
btn.textContent = '保存中...';
try {
const content = buildEnvContent();
const res = await fetch('/api/env', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
const data = await res.json();
if (data.success) {
alert(`保存成功!\n\n${data.message}`);
loadEnvFile();
} else {
alert(`保存失败: ${data.error}`);
}
} catch (e) {
alert(`保存失败: ${e.message}`);
} finally {
btn.disabled = false;
btn.textContent = '保存配置';
}
}
function buildEnvContent() {
const container = document.getElementById('envEditor');
const lines = [];
container.childNodes.forEach(node => {
if (node.classList && node.classList.contains('text-slate-400')) {
lines.push(node.textContent);
} else if (node.classList && node.classList.contains('bg-slate-50')) {
const input = node.querySelector('input');
const key = input.dataset.key;
const value = input.value;
lines.push(`${key}=${value}`);
}
});
return lines.join('\n');
}
async function loadGuide() {
const container = document.getElementById('guide-content');
try {
const res = await fetch('/api/guide');
const data = await res.json();
if (data.success && data.content) {
//使用marked.js渲染markdown
if (typeof marked !== 'undefined') {
container.innerHTML = marked.parse(data.content);
} else {
//如果marked未加载显示原始文本
container.innerHTML = `<pre>${data.content}</pre>`;
}
} else {
container.innerHTML = `
<div class="text-center py-8 text-slate-500">
<svg class="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p class="font-medium">无法加载使用指南</p>
<p class="text-sm mt-1">${data.error || '未知错误'}</p>
</div>
`;
}
} catch (e) {
console.error('加载使用指南失败:', e);
container.innerHTML = `
<div class="text-center py-8 text-rose-500">
<svg class="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="font-medium">加载失败</p>
<p class="text-sm mt-1">${e.message}</p>
</div>
`;
}
}
//页面加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
//默认显示dashboard
switchTab('dashboard');
//加载初始数据
loadDashboardData();
//定期刷新仪表盘数据每30秒
setInterval(loadDashboardData, 30000);
});

296
public/dashboard.html Normal file
View File

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TaskBot控制台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
body { font-family: 'Inter', system-ui, sans-serif; }
.log-line { font-family: 'Monaco', 'Courier New', monospace; }
.status-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .5; }
}
.markdown-body { line-height: 1.6; }
.markdown-body h1 { font-size: 2em; font-weight: bold; margin-top: 1em; margin-bottom: 0.5em; }
.markdown-body h2 { font-size: 1.5em; font-weight: bold; margin-top: 1em; margin-bottom: 0.5em; }
.markdown-body h3 { font-size: 1.25em; font-weight: bold; margin-top: 0.8em; margin-bottom: 0.4em; }
.markdown-body p { margin-bottom: 1em; }
.markdown-body ul, .markdown-body ol { margin-left: 2em; margin-bottom: 1em; }
.markdown-body li { margin-bottom: 0.5em; }
.markdown-body code { background: #f3f4f6; padding: 0.2em 0.4em; border-radius: 3px; font-family: monospace; }
.markdown-body pre { background: #1e293b; color: #e2e8f0; padding: 1em; border-radius: 0.5em; overflow-x: auto; margin-bottom: 1em; }
.markdown-body pre code { background: transparent; padding: 0; }
.markdown-body a { color: #3b82f6; text-decoration: underline; }
.markdown-body blockquote { border-left: 4px solid #e5e7eb; padding-left: 1em; color: #6b7280; margin-bottom: 1em; }
</style>
</head>
<body class="min-h-screen bg-slate-50 text-slate-900">
<div class="flex">
<!-- 侧边栏 -->
<div class="w-64 bg-slate-900 text-slate-300 flex flex-col h-screen fixed left-0 top-0 border-r border-slate-800">
<div class="h-16 flex items-center px-6 border-b border-slate-800 bg-slate-950">
<svg class="w-6 h-6 text-indigo-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<span class="font-bold text-white tracking-tight">TaskBot控制台</span>
</div>
<nav class="flex-1 py-6 px-3 space-y-1">
<button onclick="switchTab('dashboard')" id="tab-dashboard" class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
运维概览
</button>
<button onclick="switchTab('logs')" id="tab-logs" class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
运行日志
</button>
<button onclick="switchTab('mapping')" id="tab-mapping" class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
映射配置
</button>
<button onclick="switchTab('settings')" id="tab-settings" class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
系统设置
</button>
<button onclick="switchTab('guide')" id="tab-guide" class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
使用指南
</button>
</nav>
<div class="p-4 border-t border-slate-800">
<div class="flex items-center">
<div class="w-2 h-2 rounded-full bg-emerald-500 mr-2 status-pulse"></div>
<span class="text-xs font-mono text-slate-500">v1.1.0</span>
</div>
</div>
</div>
<!-- 主内容区 -->
<main class="flex-1 ml-64 p-8 overflow-y-auto">
<!-- Dashboard 标签页 -->
<div id="content-dashboard" class="tab-content">
<header class="mb-8 flex justify-between items-end">
<div>
<h1 class="text-2xl font-bold text-slate-900 tracking-tight">运维概览</h1>
<p class="text-sm text-slate-500 mt-1">Jira-Gitea双向同步机器人控制中心</p>
</div>
<div class="flex items-center space-x-2">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
<span class="text-sm font-medium text-slate-600" id="uptime">加载中...</span>
</div>
</header>
<div class="space-y-6">
<!-- 指标栏 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="bg-white p-5 rounded-lg border border-slate-200 shadow-sm">
<p class="text-sm font-medium text-slate-500">今日同步工单</p>
<p class="mt-1 text-2xl font-bold text-slate-900 tracking-tight" id="today-syncs">--</p>
<p class="mt-1 text-xs text-slate-400">实时统计</p>
</div>
<div class="bg-white p-5 rounded-lg border border-slate-200 shadow-sm">
<p class="text-sm font-medium text-slate-500">配置的仓库</p>
<p class="mt-1 text-2xl font-bold text-slate-900 tracking-tight" id="repo-count">--</p>
<p class="mt-1 text-xs text-slate-400">mappings.json</p>
</div>
<div class="bg-white p-5 rounded-lg border border-slate-200 shadow-sm">
<p class="text-sm font-medium text-slate-500">今日错误</p>
<p class="mt-1 text-2xl font-bold text-rose-600 tracking-tight" id="error-count">--</p>
<p class="mt-1 text-xs text-slate-400">ERROR + FATAL</p>
</div>
<div class="bg-white p-5 rounded-lg border border-slate-200 shadow-sm">
<p class="text-sm font-medium text-slate-500">服务状态</p>
<p class="mt-1 text-2xl font-bold text-slate-900 tracking-tight" id="service-status">运行中</p>
<p class="mt-1 text-xs text-slate-400">实时监控</p>
</div>
</div>
<!-- 历史记录 -->
<div class="bg-white rounded-lg border border-slate-200 shadow-sm p-6">
<h3 class="text-sm font-semibold text-slate-900 uppercase tracking-wider mb-4">近7日同步历史</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200">
<thead>
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">日期</th>
<th class="px-4 py-2 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">同步数</th>
<th class="px-4 py-2 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">错误数</th>
<th class="px-4 py-2 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">严重错误</th>
</tr>
</thead>
<tbody id="history-table" class="divide-y divide-slate-200">
<tr>
<td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 控制区 -->
<div class="bg-white rounded-lg border border-slate-200 shadow-sm p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-semibold text-slate-900 uppercase tracking-wider">服务控制</h3>
<span id="status-badge" class="px-3 py-1 rounded-full text-xs font-medium flex items-center border bg-emerald-50 text-emerald-600 border-emerald-200">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
运行中
</span>
</div>
<div class="grid grid-cols-3 gap-3">
<button onclick="controlBot('restart')" class="flex items-center justify-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
重启服务
</button>
<button onclick="clearLogs()" class="flex items-center justify-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
清空日志
</button>
<button onclick="refreshStatus()" class="flex items-center justify-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
刷新状态
</button>
</div>
</div>
</div>
</div>
<!-- Logs 标签页 -->
<div id="content-logs" class="tab-content hidden">
<header class="mb-6">
<h1 class="text-2xl font-bold text-slate-900 tracking-tight">运行日志</h1>
<p class="text-sm text-slate-500 mt-1">实时监控系统运行状态</p>
</header>
<div class="bg-slate-900 rounded-lg shadow-sm border border-slate-800 overflow-hidden" style="height: calc(100vh - 200px);">
<div class="bg-slate-950 px-4 py-2 border-b border-slate-800 flex justify-between items-center">
<div class="flex items-center space-x-2">
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<span class="text-xs font-mono text-slate-300" id="log-filename">加载中...</span>
</div>
<div class="flex items-center space-x-2">
<span class="w-2 h-2 rounded-full bg-emerald-500 status-pulse"></span>
<span class="text-xs text-slate-500">实时监测中</span>
</div>
</div>
<div id="log-viewer" class="overflow-y-auto p-4 font-mono text-xs text-slate-300 space-y-1" style="height: calc(100% - 44px);">
<div class="text-slate-500 text-center py-8">加载日志中...</div>
</div>
</div>
</div>
<!-- Mapping 标签页 - 嵌入原有的映射编辑器 -->
<div id="content-mapping" class="tab-content hidden">
<iframe src="/editor/error.html?code=施工中" class="w-full border-0 rounded-lg shadow-sm bg-white" style="height: calc(100vh - 100px);"></iframe>
</div>
<!-- Settings 标签页 -->
<div id="content-settings" class="tab-content hidden">
<header class="mb-6">
<h1 class="text-2xl font-bold text-slate-900 tracking-tight">系统设置</h1>
<p class="text-sm text-slate-500 mt-1">编辑环境变量配置文件 (.env)</p>
</header>
<div class="bg-white rounded-lg border border-slate-200 shadow-sm p-6">
<div class="mb-6 flex justify-between items-center border-b border-slate-200 pb-4">
<div>
<h3 class="text-sm font-semibold text-slate-900">环境变量配置</h3>
<p class="text-xs text-slate-500 mt-1">修改后需要重启服务才能生效</p>
</div>
<div class="flex gap-2">
<button onclick="loadEnvFile()" class="text-slate-600 hover:text-slate-900 px-3 py-2 rounded text-sm font-medium transition-colors border border-slate-300 hover:bg-slate-50">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
重新加载
</button>
<button onclick="saveEnvFile()" id="saveEnvBtn" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors">
保存配置
</button>
</div>
</div>
<div id="envEditor" class="space-y-3" style="max-height: 500px; overflow-y: auto;">
<div class="text-center py-8 text-slate-400">
<svg class="w-8 h-8 mx-auto mb-2 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<p class="text-sm">加载中...</p>
</div>
</div>
<div class="mt-4 bg-amber-50 border border-amber-200 rounded p-3 text-sm text-amber-800">
<div class="flex items-start">
<svg class="w-5 h-5 mr-2 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<div>
<p class="font-medium">注意事项</p>
<ul class="mt-1 text-xs space-y-1">
<li>• 修改前会自动备份为 .env.backup</li>
<li>• 保存后需要手动重启服务才能生效</li>
<li>• 请勿泄露敏感信息API Token、密码等</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Guide 标签页 -->
<div id="content-guide" class="tab-content hidden">
<header class="mb-6">
<h1 class="text-2xl font-bold text-slate-900 tracking-tight">使用指南</h1>
<p class="text-sm text-slate-500 mt-1">项目文档与配置说明</p>
</header>
<div class="bg-white rounded-lg border border-slate-200 shadow-sm p-6">
<div id="guide-content" class="markdown-body prose max-w-none text-slate-700">
<div class="text-center py-8 text-slate-500">
<svg class="w-8 h-8 mx-auto mb-2 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<p>加载中...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="/editor/dashboard-app.js"></script>
<script>
//页面加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
//默认显示运维概览
switchTab('dashboard');
});
</script>
</body>
</html>

268
public/editor.html Normal file
View File

@@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gitea-Jira 映射配置生成器</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'Inter', system-ui, sans-serif; }
.step-card { transition: all 0.3s ease; }
.step-active { border-left: 4px solid #3b82f6; }
.jira-icon { width: 16px; height: 16px; display: inline-block; vertical-align: middle; margin-right: 6px; }
</style>
</head>
<body class="bg-gray-50 text-gray-800 min-h-screen p-6">
<div class="max-w-4xl mx-auto">
<header class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Gitea-Jira 映射生成器</h1>
<p class="text-sm text-gray-500 mt-1">连接 Jira自动提取 ID生成 mappings.json 配置</p>
</div>
<div class="flex items-center gap-2">
<button onclick="openSettings()" class="text-sm bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded transition">
全局设置
</button>
<div class="text-xs bg-blue-100 text-blue-800 px-3 py-1 rounded-full">v2.0</div>
</div>
</header>
<!-- 全局设置模态窗口 -->
<div id="settingsModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-lg w-full mx-4">
<h2 class="text-xl font-bold mb-4">全局设置</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Jira 地址 (Base URL)</label>
<input type="text" id="settingsJiraUrl" placeholder="https://your-domain.atlassian.net" class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Jira 用户名/邮箱</label>
<input type="text" id="settingsJiraUser" placeholder="email@example.com" class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<p class="text-xs text-gray-400 mt-1">使用 PAT 时可留空</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Token / 密码</label>
<input type="password" id="settingsJiraToken" placeholder="ATATT3xFfGF0..." class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<p class="text-xs text-gray-400 mt-1">推荐使用 PAT (Personal Access Token)</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button onclick="closeSettings()" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded text-sm font-medium transition">
取消
</button>
<button onclick="saveSettings()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
保存设置
</button>
</div>
</div>
</div>
<!-- 改名模态窗口 -->
<div id="renameModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-lg w-full mx-4">
<h2 class="text-xl font-bold mb-4">重命名仓库</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">当前名称</label>
<input type="text" id="renameOldName" readonly class="w-full border rounded px-3 py-2 text-sm bg-gray-100 text-gray-600">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">新名称</label>
<input type="text" id="renameNewName" placeholder="例如: owner/repo" class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<p class="text-xs text-gray-400 mt-1">格式: owner/repo</p>
</div>
</div>
<div id="renameError" class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200"></div>
<div class="mt-6 flex justify-end gap-2">
<button onclick="closeRenameModal()" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded text-sm font-medium transition">
取消
</button>
<button onclick="confirmRename()" id="renameConfirmBtn" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
确认改名
</button>
</div>
</div>
</div>
<!-- Step 0: 选择/新建仓库 -->
<div id="step0" class="bg-white rounded-lg shadow p-6 mb-6 step-card step-active">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<span class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">0</span>
选择仓库配置
</h2>
<div class="space-y-4">
<div>
<button onclick="loadExistingMappings()" id="loadBtn" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded text-sm font-medium transition">
加载现有配置
</button>
<button onclick="createNewMapping()" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded text-sm font-medium transition ml-2">
新建仓库配置
</button>
</div>
<div id="repoListContainer" class="hidden">
<h3 class="text-sm font-semibold text-gray-700 mb-2">现有仓库配置:</h3>
<div id="repoList" class="space-y-2 max-h-64 overflow-y-auto"></div>
</div>
<div id="newRepoContainer" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">新仓库名称</label>
<input type="text" id="newRepoName" placeholder="例如: loren/issueBotTest" class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<button onclick="confirmNewRepo()" class="mt-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
确认并继续
</button>
</div>
</div>
<div id="step0Error" class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200"></div>
</div>
<!-- Step 1: 项目配置 -->
<div id="step1" class="bg-white rounded-lg shadow p-6 mb-6 step-card hidden">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<span class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">1</span>
项目配置
</h2>
<div class="mb-3 bg-blue-50 border border-blue-200 rounded p-3 flex items-center justify-between">
<p class="text-sm text-blue-800">
<span class="font-semibold">当前配置仓库:</span> <span id="currentRepoName" class="font-mono">-</span>
</p>
<button onclick="backToStart()" class="text-xs bg-white hover:bg-gray-50 text-gray-700 px-3 py-1 rounded border">
返回
</button>
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">目标 Jira 项目 Key</label>
<input type="text" id="projectKey" placeholder="例如: TEST" class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none uppercase font-bold text-gray-700">
</div>
<!-- Sprint ID 提取区域 -->
<div class="mt-4 border-t pt-4">
<h3 class="text-sm font-semibold text-gray-700 mb-2">配置 Sprint 映射(可选)</h3>
<p class="text-xs text-gray-500 mb-3">输入一个已在目标 Sprint 中的工单 Key系统将自动提取 Sprint ID 并建立映射</p>
<div class="flex gap-2">
<input type="text" id="issueKey" placeholder="例如: TEST-196" class="w-1/5 border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none uppercase">
<button onclick="fetchSprintId()" id="fetchSprintBtn" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded text-sm font-medium transition whitespace-nowrap">
提取 Sprint
</button>
<div id="sprintResult" class="flex-1 flex items-center px-3 text-sm"></div>
</div>
<div id="sprintMilestone" class="hidden mt-3 flex gap-2 items-center">
<label class="text-xs font-medium text-gray-600 whitespace-nowrap">里程碑名称:</label>
<input type="text" id="milestoneName" placeholder="例如: v1.0.1" class="flex-1 border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<button onclick="addSprintMapping()" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded text-sm font-medium transition whitespace-nowrap">
添加映射
</button>
</div>
<div id="sprintError" class="hidden mt-2 bg-red-50 text-red-700 p-2 rounded text-xs border border-red-200"></div>
<!-- Sprint 映射列表 -->
<div id="sprintListContainer" class="hidden mt-4">
<h4 class="text-xs font-semibold text-gray-600 mb-2">已添加的 Sprint 映射:</h4>
<div id="sprintList" class="space-y-2"></div>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button onclick="scanJira()" id="scanBtn" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded shadow text-sm font-medium transition flex items-center">
<span id="scanBtnText">开始扫描</span>
</button>
</div>
<div id="scanError" class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200"></div>
</div>
<!-- Step 2: 交互映射 -->
<div id="step2" class="bg-white rounded-lg shadow p-6 mb-6 hidden">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<span class="bg-blue-100 text-blue-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">2</span>
配置映射关系
</h2>
<div class="space-y-6">
<!-- 优先级 -->
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide border-b pb-2 mb-3">优先级映射 (Priority)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="priorityContainer">
<!-- 动态生成 -->
</div>
</div>
<!-- 工单类型 -->
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide border-b pb-2 mb-3">类型映射 (不填写的类型不会同步)(Issue Types)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4" id="typeContainer">
<!-- 动态生成 -->
</div>
<div class="mt-2 text-sm text-gray-600 flex items-center gap-2">
<label>默认类型设置后即使Gitea没打标签也会被同步到Jira:</label>
<select id="defaultTypeSelect" class="border rounded px-2 py-1 text-sm bg-gray-50"></select>
</div>
</div>
<!-- 状态流转 -->
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide border-b pb-2 mb-3">状态动作 (Transitions)</h3>
<div class="bg-yellow-50 border border-yellow-200 rounded p-3 mb-3 text-xs text-yellow-800 hidden" id="transWarning"></div>
<div class="grid grid-cols-1 gap-3">
<div class="flex items-center justify-between bg-gray-50 p-3 rounded border">
<span class="font-mono text-sm font-medium text-gray-700">关闭 (Close)</span>
<select id="transClose" class="border rounded px-2 py-1 text-sm w-1/2"></select>
</div>
<div class="flex items-center justify-between bg-gray-50 p-3 rounded border">
<span class="font-mono text-sm font-medium text-gray-700">重开 (Reopen)</span>
<select id="transReopen" class="border rounded px-2 py-1 text-sm w-1/2"></select>
</div>
<div class="flex items-center justify-between bg-gray-50 p-3 rounded border">
<span class="font-mono text-sm font-medium text-gray-700">处理中 (In Progress)</span>
<select id="transProgress" class="border rounded px-2 py-1 text-sm w-1/2"></select>
</div>
</div>
</div>
</div>
<!-- JSON 预览和编辑区 -->
<div class="mt-6 border-t pt-6">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wide">配置预览(可编辑)</h3>
<button onclick="updatePreview()" class="text-xs bg-blue-50 hover:bg-blue-100 text-blue-600 px-3 py-1 rounded transition">
刷新预览
</button>
</div>
<div class="relative">
<textarea id="jsonPreview" class="w-full h-96 bg-gray-900 text-gray-100 font-mono text-xs p-4 rounded outline-none border-2 border-gray-700 focus:border-blue-500 transition" spellcheck="false" placeholder="配置将在此显示,可直接编辑..."></textarea>
<div id="jsonError" class="hidden mt-2 bg-red-50 text-red-700 p-2 rounded text-xs border border-red-200"></div>
</div>
</div>
<div class="mt-6 flex justify-end border-t pt-4">
<button onclick="saveToFile()" class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded shadow text-sm font-medium transition">
保存到 mappings.json
</button>
</div>
</div>
<!-- Step 3: 保存结果 -->
<div id="step3" class="bg-white rounded-lg shadow p-6 mb-12 hidden">
<h2 class="text-lg font-semibold mb-4 flex items-center justify-between">
<div class="flex items-center">
<span class="bg-green-100 text-green-600 rounded-full w-8 h-8 flex items-center justify-center text-sm mr-3">3</span>
保存成功
</div>
</h2>
<div id="saveResult" class="text-sm"></div>
<div class="mt-4">
<button onclick="location.reload()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
继续配置其他仓库
</button>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

61
public/error.html Normal file
View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>错误 - TaskBot</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'Inter', system-ui, sans-serif; }
</style>
</head>
<body class="min-h-screen bg-slate-50 flex items-center justify-center">
<div class="max-w-md w-full px-6">
<div class="text-center">
<div class="mb-6">
<svg class="w-24 h-24 mx-auto text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<h1 class="text-6xl font-bold text-slate-900 mb-2" id="error-code">404</h1>
<h2 class="text-xl font-medium text-slate-700 mb-4" id="error-title">页面未找到</h2>
<p class="text-sm text-slate-500 mb-8" id="error-message">抱歉,您访问的页面不存在或已被移除。</p>
<div class="space-y-3">
<button onclick="window.parent.switchTab('dashboard')" class="block w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-3 px-6 rounded-lg transition-colors">
返回控制台
</button>
</div>
</div>
<div class="mt-12 text-center">
<p class="text-xs text-slate-400">TaskBot v1.1.0</p>
</div>
</div>
<script>
const params = new URLSearchParams(window.location.search);
const code = params.get('code') || '404';
const title = params.get('title') || '';
const message = params.get('message') || '';
const errorMap = {
'400': { title: '请求错误', message: '请求参数有误,请检查后重试。' },
'401': { title: '未授权', message: '您没有权限访问此资源,请先登录。' },
'403': { title: '禁止访问', message: '您没有权限访问此页面。' },
'404': { title: '页面未找到', message: '抱歉,您访问的页面不存在或已被移除。' },
'500': { title: '服务器错误', message: '服务器遇到了一些问题,请稍后再试。' },
'503': { title: '服务不可用', message: '服务暂时不可用,请稍后再试。' },
'施工中': { title: '图形化编辑映射功能暂不可用', message: '该功能预计2026/01/30下午上线' }
};
const error = errorMap[code] || errorMap['404'];
document.getElementById('error-code').textContent = code;
document.getElementById('error-title').textContent = title || error.title;
document.getElementById('error-message').textContent = message || error.message;
document.title = `${code} ${error.title} - TaskBot`;
</script>
</body>
</html>

56
src/config/env.js Normal file
View File

@@ -0,0 +1,56 @@
require('dotenv').config();
const config = {
app: {
port: process.env.PORT || 3000,
dbPath: process.env.DB_FILE_PATH || './sync.sqlite',
rate : process.env.RATE_LIMIT_WINDOW || 10000,
maxRequests: process.env.MAX_REQUESTS_PER_WINDOW || 20,
debugMode: process.env.DEBUG_MODE === 'true',
logRetentionDays: process.env.LOG_RETENTION_DAYS || 30
},
gitea: {
baseUrl: process.env.GITEA_BASE_URL,
token: process.env.GITEA_TOKEN,
secret: process.env.GITEA_WEBHOOK_SECRET,
botId: parseInt(process.env.GITEA_BOT_ID || '0'),
botName: process.env.GITEA_BOT_NAME
},
jira: {
baseUrl: process.env.JIRA_BASE_URL,
botId: process.env.JIRA_BOT_ID || '',
botName: process.env.JIRA_BOT_NAME
}
};
const requiredVars = [
{ key: 'GITEA_BASE_URL', value: config.gitea.baseUrl },
{ key: 'GITEA_TOKEN', value: config.gitea.token },
{ key: 'GITEA_WEBHOOK_SECRET', value: config.gitea.secret },
{ key: 'JIRA_BASE_URL', value: config.jira.baseUrl }
];
const missingVars = requiredVars.filter(v => !v.value).map(v => v.key);
if (missingVars.length > 0) {
console.error(`[ERROR] Missing required environment variables: ${missingVars.join(', ')}`);
console.error('[ERROR] Please configure these in your .env file');
process.exit(1);
}
if (process.env.JIRA_PAT) {
//个人访问令牌鉴权
config.jira.authHeader = {
'Authorization': `Bearer ${process.env.JIRA_PAT}`
};
} else if (process.env.JIRA_USERNAME && process.env.JIRA_PASSWORD) {
//账号密码鉴权
const authString = Buffer.from(`${process.env.JIRA_USERNAME}:${process.env.JIRA_PASSWORD}`).toString('base64');
config.jira.authHeader = {
'Authorization': `Basic ${authString}`
};
} else {
console.error("[ERROR] Missing JIRA authentication: Please configure JIRA_PAT or JIRA_USERNAME/PASSWORD");
process.exit(1);
}
module.exports = config;

144
src/config/mappings.js Normal file
View File

@@ -0,0 +1,144 @@
const fs = require('fs');
const path = require('path');
// 读取配置文件路径
const configPath = path.join(__dirname, '../../mappings.json');
let mappingsConfig = null;
/**
* 加载映射配置文件
* @returns {Object} 配置对象
*/
function loadMappings() {
if (mappingsConfig) {
return mappingsConfig;
}
try {
const configContent = fs.readFileSync(configPath, 'utf8');
mappingsConfig = JSON.parse(configContent);
// 处理环境变量替换
processEnvVariables(mappingsConfig);
return mappingsConfig;
} catch (error) {
throw new Error(`无法加载映射配置文件 ${configPath}: ${error.message}`);
}
}
/**
* 递归处理配置中的环境变量
* @param {Object} obj 配置对象
*/
function processEnvVariables(obj) {
for (const key in obj) {
if (typeof obj[key] === 'string' && obj[key].startsWith('${') && obj[key].endsWith('}')) {
const envVar = obj[key].slice(2, -1);
obj[key] = process.env[envVar] || obj[key];
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
processEnvVariables(obj[key]);
}
}
}
/**
* 获取所有仓库配置
* @returns {Object} 仓库配置对象
*/
const repositories = new Proxy({}, {
get(target, prop) {
const config = loadMappings();
return config.repositories[prop];
},
ownKeys() {
const config = loadMappings();
return Object.keys(config.repositories);
},
getOwnPropertyDescriptor(target, prop) {
const config = loadMappings();
if (prop in config.repositories) {
return {
enumerable: true,
configurable: true
};
}
}
});
/**
* 获取默认映射配置
* @returns {Object} 默认映射配置
*/
const defaultMappings = new Proxy({}, {
get(target, prop) {
const config = loadMappings();
return config.defaultMappings[prop];
}
});
/**
* 获取仓库配置
* @param {string} repoFullName 仓库完整名称 (owner/repo)
* @returns {Object|null} 仓库配置对象,如果不存在返回 null
*/
function getRepoConfig(repoFullName) {
const config = loadMappings();
const repoConfig = config.repositories[repoFullName];
if (!repoConfig) {
return null;
}
return {
jira: repoConfig.jira,
priorities: repoConfig.priorities || config.defaultMappings.priorities,
types: repoConfig.types || config.defaultMappings.types,
sprints: repoConfig.sprints || config.defaultMappings.sprints,
transitions: repoConfig.transitions || config.defaultMappings.transitions
};
}
/**
* 获取所有已配置的仓库列表
* @returns {string[]} 仓库名称数组
*/
function getConfiguredRepos() {
const config = loadMappings();
return Object.keys(config.repositories);
}
/**
* 根据Jira项目Key反查对应的Gitea仓库
* 返回第一个匹配的仓库配置如果一个Jira项目对应多个Gitea仓库只返回第一个
* @param {string} jiraProjectKey Jira项目Key
* @returns {Object|null} 包含仓库Key和配置的对象如果不存在返回 null
*/
function getRepoByJiraProject(jiraProjectKey) {
const config = loadMappings();
for (const [repoKey, repoConfig] of Object.entries(config.repositories)) {
if (repoConfig.jira && repoConfig.jira.projectKey === jiraProjectKey) {
return {
repoKey,
config: {
jira: repoConfig.jira,
priorities: repoConfig.priorities || config.defaultMappings.priorities,
types: repoConfig.types || config.defaultMappings.types,
sprints: repoConfig.sprints || config.defaultMappings.sprints,
transitions: repoConfig.transitions || config.defaultMappings.transitions
}
};
}
}
return null;
}
module.exports = {
repositories,
defaultMappings,
getRepoConfig,
getConfiguredRepos,
getRepoByJiraProject
};

48
src/db/connection.js Normal file
View File

@@ -0,0 +1,48 @@
const Database = require('better-sqlite3');
const config = require('../config/env');
const path = require('path');
const logger = require('../utils/logger');
//自动创建数据库连接
const dbPath = config.app.dbPath || path.join(__dirname, '../../sync.sqlite');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS issue_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_key TEXT NOT NULL, -- 仓库标识 (owner/repo格式)
gitea_id INTEGER NOT NULL, -- Gitea Issue Number
jira_key TEXT NOT NULL, -- Jira Key (e.g., LTM-123)
jira_id TEXT NOT NULL, -- Jira Internal ID
UNIQUE(repo_key, gitea_id) -- 同一仓库的Issue不能重复
)
`);
const cleanup = () => {
try {
logger.info('Closing database connection...');
db.close();
logger.info('Database connection closed');
} catch (err) {
logger.error('Failed to close database', err.message);
}
};
process.on('SIGINT', () => {
cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(0);
});
process.on('exit', () => {
if (db.open) {
cleanup();
}
});
logger.info(`Database connected at ${dbPath}`);
module.exports = db;

33
src/db/issueMap.js Normal file
View File

@@ -0,0 +1,33 @@
const db = require('./connection');
//根据仓库标识和GiteaID查询Jira工单信息
function getJiraKey(repoKey, giteaId) {
const row = db.prepare('SELECT jira_key, jira_id FROM issue_mapping WHERE repo_key = ? AND gitea_id = ?').get(repoKey, giteaId);
return row || null; //返回{jira_key,jira_id}或null
}
//根据Jira工单Key查询Gitea信息
function getGiteaInfo(jiraKey) {
const row = db.prepare('SELECT repo_key, gitea_id FROM issue_mapping WHERE jira_key = ?').get(jiraKey);
return row || null;
}
//保存仓库Issue与Jira工单的映射关系
function saveMapping(repoKey, giteaId, jiraKey, jiraId) {
const stmt = db.prepare('INSERT OR IGNORE INTO issue_mapping (repo_key, gitea_id, jira_key, jira_id) VALUES (?, ?, ?, ?)');
stmt.run(repoKey, giteaId, jiraKey, jiraId);
}
//获取指定仓库的所有映射记录
function getMappingsByRepo(repoKey) {
const rows = db.prepare('SELECT gitea_id, jira_key, jira_id FROM issue_mapping WHERE repo_key = ?').all(repoKey);
return rows;
}
//统计各仓库的同步数量
function getStats() {
const rows = db.prepare('SELECT repo_key, COUNT(*) as count FROM issue_mapping GROUP BY repo_key').all();
return rows;
}
module.exports = { getJiraKey, getGiteaInfo, saveMapping, getMappingsByRepo, getStats };

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

535
src/routes/editor.js Normal file
View File

@@ -0,0 +1,535 @@
/**
* 映射关系编辑器路由模块
* 提供映射配置的 CRUD 操作和 Jira API 代理
*/
const { Hono } = require('hono');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const logger = require('../utils/logger');
const editor = new Hono();
const MAPPINGS_PATH = path.join(__dirname, '../../mappings.json');
const LOGS_DIR = path.join(__dirname, '../../logs');
const README_PATH = path.join(__dirname, '../../how-to-use.md');
editor.get('/status', (c) => {
try {
let repoCount = 0;
if (fs.existsSync(MAPPINGS_PATH)) {
const config = JSON.parse(fs.readFileSync(MAPPINGS_PATH, 'utf8'));
repoCount = Object.keys(config.repositories || {}).length;
}
const uptime = process.uptime();
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const uptimeStr = days > 0 ? `${days}${hours}小时` : `${hours}小时 ${minutes}分钟`;
const today = new Date().toISOString().split('T')[0];
const logFile = path.join(LOGS_DIR, `sync-${today}.log`);
let todaySyncs = 0;
let errorCount = 0;
let fatalCount = 0;
if (fs.existsSync(logFile)) {
const content = fs.readFileSync(logFile, 'utf8');
const createdMatches = content.match(/Created/g);
todaySyncs = createdMatches ? createdMatches.length : 0;
const errorMatches = content.match(/\[ERROR\]/g);
errorCount = errorMatches ? errorMatches.length : 0;
const fatalMatches = content.match(/\[FATAL\]/g);
fatalCount = fatalMatches ? fatalMatches.length : 0;
}
return c.json({
success: true,
status: 'running',
repoCount,
todaySyncs,
errorCount,
fatalCount,
uptime: uptimeStr
});
} catch (e) {
logger.error('[Editor] Get status error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//获取历史统计数据
editor.get('/history', (c) => {
try {
const history = [];
//读取最近7天的日志文件
for (let i = 0; i < 7; i++) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const logFile = path.join(LOGS_DIR, `sync-${dateStr}.log`);
if (fs.existsSync(logFile)) {
const content = fs.readFileSync(logFile, 'utf8');
//统计各项指标
const createdMatches = content.match(/Created/g);
const errorMatches = content.match(/\[ERROR\]/g);
const fatalMatches = content.match(/\[FATAL\]/g);
history.push({
date: dateStr,
syncs: createdMatches ? createdMatches.length : 0,
errors: errorMatches ? errorMatches.length : 0,
fatals: fatalMatches ? fatalMatches.length : 0
});
} else {
history.push({
date: dateStr,
syncs: 0,
errors: 0,
fatals: 0
});
}
}
return c.json({
success: true,
history: history.reverse() //从旧到新排序
});
} catch (e) {
logger.error('[Editor] Get history error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//获取当日日志
editor.get('/logs', (c) => {
try {
//获取今天的日志文件
const today = new Date().toISOString().split('T')[0]; //YYYY-MM-DD
const logFile = path.join(LOGS_DIR, `sync-${today}.log`);
if (!fs.existsSync(logFile)) {
return c.json({
success: true,
filename: `sync-${today}.log`,
logs: ['[INFO] 今日暂无日志记录']
});
}
//读取日志文件最后1000行
const content = fs.readFileSync(logFile, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
const recentLogs = lines.slice(-1000); //只返回最后1000行
return c.json({
success: true,
filename: `sync-${today}.log`,
logs: recentLogs
});
} catch (e) {
logger.error('[Editor] Get logs error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//清空当日日志
editor.post('/logs/clear', (c) => {
try {
const today = new Date().toISOString().split('T')[0];
const logFile = path.join(LOGS_DIR, `sync-${today}.log`);
if (fs.existsSync(logFile)) {
fs.writeFileSync(logFile, '', 'utf8');
logger.info('[Editor] Logs cleared');
}
return c.json({ success: true, message: '日志已清空' });
} catch (e) {
logger.error('[Editor] Clear logs error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//控制机器人(重启等)
editor.post('/control', async (c) => {
try {
const { action } = await c.req.json();
logger.info(`[Editor] Control action received: ${action}`);
//注意:实际的重启需要外部进程管理器(如 PM2
//这里只是记录日志
if (action === 'restart') {
logger.info('[Editor] Restart requested (requires PM2 or similar)');
return c.json({
success: true,
message: '重启请求已记录,请使用 PM2 或其他进程管理器执行重启'
});
}
return c.json({
success: false,
error: '不支持的操作'
});
} catch (e) {
logger.error('[Editor] Control error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//读取 .env 文件
editor.get('/env', (c) => {
try {
const envPath = path.join(__dirname, '../../.env');
if (!fs.existsSync(envPath)) {
return c.json({
success: true,
content: '# 环境变量配置文件\n# 请根据需要配置以下变量\n'
});
}
const content = fs.readFileSync(envPath, 'utf8');
return c.json({ success: true, content });
} catch (e) {
logger.error('[Editor] Read .env error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//保存 .env 文件
editor.post('/env', async (c) => {
try {
const { content } = await c.req.json();
const envPath = path.join(__dirname, '../../.env');
//备份现有文件
if (fs.existsSync(envPath)) {
const backupPath = path.join(__dirname, '../../.env.backup');
fs.copyFileSync(envPath, backupPath);
logger.info('[Editor] .env file backed up');
}
//写入新内容
fs.writeFileSync(envPath, content, 'utf8');
logger.info('[Editor] .env file updated');
return c.json({
success: true,
message: '配置已保存,重启服务后生效'
});
} catch (e) {
logger.error('[Editor] Save .env error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
editor.get('/guide', (c) => {
try {
if (!fs.existsSync(README_PATH)) {
return c.json({
success: false,
error: 'how-to-use.md not found',
content: '# 使用指南\n\n使用指南文件不存在'
});
}
const content = fs.readFileSync(README_PATH, 'utf8');
return c.json({ success: true, content });
} catch (e) {
logger.error('[Editor] Read guide error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//读取现有的 mappings.json
editor.get('/mappings', (c) => {
try {
if (!fs.existsSync(MAPPINGS_PATH)) {
return c.json({ success: true, data: { repositories: {} } });
}
const content = fs.readFileSync(MAPPINGS_PATH, 'utf8');
const config = JSON.parse(content);
return c.json({ success: true, data: config });
} catch (e) {
logger.error('[Editor] Read mappings error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//保存/更新 mappings.json
editor.post('/mappings', async (c) => {
try {
const { repoName, config } = await c.req.json();
let fullConfig = { repositories: {} };
//读取现有配置
if (fs.existsSync(MAPPINGS_PATH)) {
const content = fs.readFileSync(MAPPINGS_PATH, 'utf8');
fullConfig = JSON.parse(content);
}
//确保结构存在
if (!fullConfig.repositories) fullConfig.repositories = {};
//更新指定仓库的配置
fullConfig.repositories[repoName] = config;
//写回文件
fs.writeFileSync(MAPPINGS_PATH, JSON.stringify(fullConfig, null, 2), 'utf8');
logger.info(`[Editor] Updated configuration for ${repoName}`);
return c.json({ success: true, message: `配置已保存到 mappings.json` });
} catch (e) {
logger.error('[Editor] Save mappings error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//删除仓库配置
editor.delete('/mappings/:repoName', async (c) => {
try {
const repoName = decodeURIComponent(c.req.param('repoName'));
if (!fs.existsSync(MAPPINGS_PATH)) {
return c.json({ success: false, error: '配置文件不存在' }, 404);
}
//读取现有配置
const content = fs.readFileSync(MAPPINGS_PATH, 'utf8');
const fullConfig = JSON.parse(content);
//检查仓库是否存在
if (!fullConfig.repositories || !fullConfig.repositories[repoName]) {
return c.json({ success: false, error: '仓库配置不存在' }, 404);
}
//删除指定仓库
delete fullConfig.repositories[repoName];
//写回文件
fs.writeFileSync(MAPPINGS_PATH, JSON.stringify(fullConfig, null, 2), 'utf8');
logger.info(`[Editor] Deleted configuration for ${repoName}`);
return c.json({ success: true, message: `仓库配置已删除` });
} catch (e) {
logger.error('[Editor] Delete mappings error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//改名仓库配置
editor.post('/mappings/rename', async (c) => {
try {
const { oldName, newName } = await c.req.json();
if (!oldName || !newName) {
return c.json({ success: false, error: '缺少必要参数' }, 400);
}
if (!fs.existsSync(MAPPINGS_PATH)) {
return c.json({ success: false, error: '配置文件不存在' }, 404);
}
//读取现有配置
const content = fs.readFileSync(MAPPINGS_PATH, 'utf8');
const fullConfig = JSON.parse(content);
//检查旧名称是否存在
if (!fullConfig.repositories || !fullConfig.repositories[oldName]) {
return c.json({ success: false, error: '源仓库配置不存在' }, 404);
}
//检查新名称是否已存在
if (fullConfig.repositories[newName]) {
return c.json({ success: false, error: '目标仓库名称已存在' }, 400);
}
//复制配置到新名称
fullConfig.repositories[newName] = fullConfig.repositories[oldName];
//删除旧名称
delete fullConfig.repositories[oldName];
//写回文件
fs.writeFileSync(MAPPINGS_PATH, JSON.stringify(fullConfig, null, 2), 'utf8');
logger.info(`[Editor] Renamed configuration from ${oldName} to ${newName}`);
return c.json({ success: true, message: `仓库配置已改名` });
} catch (e) {
logger.error('[Editor] Rename mappings error:', e.message);
return c.json({ success: false, error: e.message }, 500);
}
});
//保存配置接口(兼容旧版)
editor.post('/save', async (c) => {
try {
const newConfigObj = await c.req.json();
const repoName = Object.keys(newConfigObj)[0];
const repoData = newConfigObj[repoName];
let fullConfig = { repositories: {}, defaultMappings: {} };
//1. 读取现有文件(保留 guide, comment 等字段)
if (fs.existsSync(MAPPINGS_PATH)) {
try {
const content = fs.readFileSync(MAPPINGS_PATH, 'utf8');
fullConfig = JSON.parse(content);
} catch (e) {
logger.error("[Editor] JSON Parse Error, creating backup", e.message);
fs.copyFileSync(MAPPINGS_PATH, MAPPINGS_PATH + '.bak');
}
}
//2. 确保结构存在
if (!fullConfig.repositories) fullConfig.repositories = {};
//3. 更新特定仓库
fullConfig.repositories[repoName] = repoData;
//4. 写回文件
fs.writeFileSync(MAPPINGS_PATH, JSON.stringify(fullConfig, null, 2), 'utf8');
logger.info(`[Editor] Saved configuration for ${repoName}`);
return c.json({ success: true });
} catch (error) {
logger.error("[Editor] Save Error:", error.message);
return c.json({ success: false, error: error.message }, 500);
}
});
//扫描 Jira 项目信息
editor.post('/scan', async (c) => {
const { baseUrl, auth, projectKey: rawKey } = await c.req.json();
const inputKey = rawKey ? rawKey.trim() : '';
//构造认证头
let headers = { 'Accept': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
else if (auth.username) headers['Authorization'] = `Basic ${Buffer.from(auth.username + ':' + auth.password).toString('base64')}`;
const client = axios.create({ baseURL: baseUrl.replace(/\/$/, ''), headers, timeout: 15000 });
try {
let projectData, realKey = inputKey;
//尝试获取项目
try {
projectData = (await client.get(`/rest/api/2/project/${inputKey}`)).data;
} catch (e) {
if (e.response?.status === 404) {
//404 尝试列表搜索
const list = (await client.get('/rest/api/2/project')).data;
const found = list.find(p => p.key.toLowerCase() === inputKey.toLowerCase());
if (!found) throw new Error("Project not found (Check permissions/key)");
projectData = (await client.get(`/rest/api/2/project/${found.id}`)).data;
realKey = found.key;
} else throw e;
}
const result = {
project: { id: projectData.id, key: projectData.key, name: projectData.name },
types: projectData.issueTypes.filter(t => !t.subtask).map(t => ({ id: t.id, name: t.name, iconUrl: t.iconUrl })),
priorities: (await client.get('/rest/api/2/priority')).data.map(p => ({ id: p.id, name: p.name, iconUrl: p.iconUrl })),
transitions: [], sampleIssueKey: null
};
//尝试获取流转 - 从不同状态的工单收集所有可能的流转
const transitionsMap = new Map();
let sampleIssues = [];
try {
//获取多个工单以覆盖不同状态
const search = await client.get(`/rest/api/2/search?jql=project="${realKey}"&maxResults=20&fields=id,key,status`);
if (search.data.issues?.length > 0) {
sampleIssues = search.data.issues;
result.sampleIssueKey = sampleIssues[0].key;
//对每个工单获取其可用的transitions
const transPromises = sampleIssues.slice(0, 15).map(issue =>
client.get(`/rest/api/2/issue/${issue.key}/transitions`)
.then(trans => trans.data.transitions)
.catch(() => [])
);
const allTransitions = await Promise.all(transPromises);
//合并所有transitions并去重
allTransitions.flat().forEach(t => {
if (!transitionsMap.has(t.id)) {
transitionsMap.set(t.id, { id: t.id, name: t.name, to: t.to?.name || 'Unknown' });
}
});
result.transitions = Array.from(transitionsMap.values());
if (result.transitions.length > 0) {
result.warning = `已从 ${sampleIssues.length} 个工单中扫描到 ${result.transitions.length} 个状态流转。注意Jira的流转取决于工单当前状态未被扫描的必须手动配置。`;
}
}
} catch (e) {
result.warning = `无法获取完整的状态流转信息: ${e.message}`;
}
return c.json({ success: true, data: result });
} catch (e) {
return c.json({ success: false, error: e.message }, 500);
}
});
//扫描 Sprint 信息
editor.post('/scan-sprint', async (c) => {
const { baseUrl, auth, issueKey } = await c.req.json();
let headers = { 'Accept': 'application/json' };
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
else if (auth.username) headers['Authorization'] = `Basic ${Buffer.from(auth.username + ':' + auth.password).toString('base64')}`;
try {
const client = axios.create({ baseURL: baseUrl.replace(/\/$/, ''), headers, timeout: 10000 });
const fields = (await client.get(`/rest/api/2/issue/${issueKey}`)).data.fields;
let fieldId = null, sprints = [];
for (const [k, v] of Object.entries(fields)) {
if (Array.isArray(v) && v[0]?.toString().includes('com.atlassian.greenhopper.service.sprint.Sprint')) {
fieldId = k;
v.forEach(s => {
const id = s.match(/id=(\d+)/)?.[1];
const name = s.match(/name=([^,\]]+)/)?.[1];
if (id && name) sprints.push({ id, name });
});
}
}
return c.json({ success: true, data: { fieldId, sprints } });
} catch (e) {
return c.json({ success: false, error: e.message }, 500);
}
});
//代理 Jira API 请求
editor.post('/proxy-jira', async (c) => {
const { url, auth } = await c.req.json();
try {
const response = await axios.get(url, {
headers: {
'Authorization': auth,
'Accept': 'application/json'
},
timeout: 10000
});
return c.json({ success: true, data: response.data });
} catch (e) {
logger.error('[Editor] Proxy Jira Error:', e.message);
return c.json({
success: false,
error: e.response?.data?.errorMessages?.[0] || e.message
}, 500);
}
});
module.exports = editor;

87
src/services/gitea.js Normal file
View File

@@ -0,0 +1,87 @@
const axios = require('axios');
const config = require('../config/env');
const logger = require('../utils/logger');
const giteaClient = axios.create({
baseURL: config.gitea.baseUrl,
headers: {
'Authorization': `token ${config.gitea.token}`,
'Content-Type': 'application/json'
}
});
//回写评论
async function addComment(repoOwner, repoName, issueIndex, body) {
try {
await giteaClient.post(`/repos/${repoOwner}/${repoName}/issues/${issueIndex}/comments`, { body });
logger.info(`Gitea commented on #${issueIndex}`);
} catch (error) {
logger.error(`Gitea add comment failed (#${issueIndex})`, error.message);
}
}
//更新工单
async function updateIssue(repoOwner, repoName, issueIndex, data) {
try {
await giteaClient.patch(`/repos/${repoOwner}/${repoName}/issues/${issueIndex}`, data);
logger.info(`Gitea updated issue #${issueIndex}`);
} catch (error) {
logger.error(`Gitea update failed (#${issueIndex})`, error.message);
}
}
//创建工单用于Jira->Gitea反向同步
async function createIssue(repoOwner, repoName, data) {
try {
const response = await giteaClient.post(`/repos/${repoOwner}/${repoName}/issues`, data);
logger.info(`Gitea created issue #${response.data.number} in ${repoOwner}/${repoName}`);
return response.data;
} catch (error) {
logger.error(`Gitea create issue failed`, error.message);
throw error;
}
}
//获取工单详情
async function getIssue(repoOwner, repoName, issueIndex) {
try {
const response = await giteaClient.get(`/repos/${repoOwner}/${repoName}/issues/${issueIndex}`);
return response.data;
} catch (error) {
logger.error(`Gitea get issue failed (#${issueIndex})`, error.message);
return null;
}
}
//获取仓库的所有里程碑
async function getMilestones(repoOwner, repoName) {
try {
const response = await giteaClient.get(`/repos/${repoOwner}/${repoName}/milestones`, {
params: { state: 'all' }
});
return response.data || [];
} catch (error) {
logger.error(`Gitea get milestones failed`, error.message);
return [];
}
}
//替换工单标签 (Gitea API需要使用专门的标签接口)
async function replaceLabels(repoOwner, repoName, issueIndex, labelNames) {
try {
//Gitea API: PUT /repos/{owner}/{repo}/issues/{index}/labels
//需要传递 { labels: [label_id, ...] } 或 { labels: ["label_name", ...] }
//不同版本API可能不同尝试使用labels数组
const response = await giteaClient.put(
`/repos/${repoOwner}/${repoName}/issues/${issueIndex}/labels`,
{ labels: labelNames }
);
logger.info(`Gitea replaced labels on #${issueIndex}: ${labelNames.join(', ')}`);
return response.data;
} catch (error) {
logger.error(`Gitea replace labels failed (#${issueIndex})`, error.response?.data || error.message);
throw error;
}
}
module.exports = { addComment, updateIssue, createIssue, getIssue, getMilestones, replaceLabels };

93
src/services/jira.js Normal file
View File

@@ -0,0 +1,93 @@
const axios = require('axios');
const config = require('../config/env');
const logger = require('../utils/logger');
//创建Jira API客户端(共用认证信息baseURL来自全局配置)
const jiraClient = axios.create({
baseURL: config.jira.baseUrl,
headers: {
...config.jira.authHeader,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
//创建工单
async function createIssue(fields) {
try {
const res = await jiraClient.post('/rest/api/2/issue', { fields });
logger.info(`Jira created issue: ${res.data.key}`);
return res.data;
} catch (error) {
logger.error('Jira create issue failed', error.response?.data || error.message);
throw error;
}
}
//更新工单
async function updateIssue(key, fields) {
try {
await jiraClient.put(`/rest/api/2/issue/${key}`, { fields });
logger.info(`Jira updated ${key}`);
} catch (error) {
logger.error(`Jira update issue failed (${key})`, error.response?.data || error.message);
throw error;
}
}
//执行状态转换
async function transitionIssue(key, transitionId) {
try {
await jiraClient.post(`/rest/api/2/issue/${key}/transitions`, {
transition: { id: transitionId }
});
logger.info(`Jira transitioned ${key} to state ID ${transitionId}`);
} catch (error) {
logger.error(`Jira transition failed (${key})`, error.response?.data || error.message);
throw error;
}
}
//添加评论
async function addComment(key, body) {
try {
await jiraClient.post(`/rest/api/2/issue/${key}/comment`, { body });
logger.info(`Jira added comment to ${key}`);
} catch (error) {
logger.error(`Jira add comment failed (${key})`, error.message);
}
}
//查找用户,支持精确匹配和模糊匹配
async function findUser(query) {
if (!query) return null;
try {
const res = await jiraClient.get('/rest/api/2/user/search', {
params: {
username: query,
maxResults: 10
}
});
if (res.data && res.data.length > 0) {
const exactMatch = res.data.find(u =>
u.name === query ||
u.key === query ||
u.emailAddress === query ||
u.displayName === query
);
if (exactMatch) {
logger.info(`Found exact user match: ${exactMatch.name}`);
return exactMatch;
}
logger.info(`Using partial match for user: ${res.data[0].name}`);
return res.data[0];
}
logger.warn(`No user found for query: "${query}"`);
return null;
} catch (error) {
logger.warn(`User search failed for "${query}"`, error.message);
return null;
}
}
module.exports = { createIssue, updateIssue, addComment, transitionIssue, findUser };

View File

@@ -0,0 +1,34 @@
const logger = require('./logger');
const config = require('../config/env');
//全局限流配置
let requestCount = 0;
let lastResetTime = Date.now();
//熔断检查器
function checkCircuitBreaker() {
const now = Date.now();
if (now - lastResetTime > config.app.rate) {
requestCount = 0;
lastResetTime = now;
}
requestCount++;
if (requestCount > config.app.maxRequests) {
const msg = `Circuit breaker triggered: Exceeded ${config.app.maxRequests} requests in ${config.app.rate/1000}s. Exiting...`;
//同步写入fatal日志
logger.fatal("============================");
logger.fatal(`${msg}`);
logger.fatal("============================");
if (!config.app.debugMode) {
process.exit(1);
} else {
logger.warn('Debug mode enabled, not exiting.');
requestCount = 0;
}
return false;
}
return true;
}
module.exports = { checkCircuitBreaker };

149
src/utils/logger.js Normal file
View File

@@ -0,0 +1,149 @@
const config = require('../config/env');
const fs = require('fs');
const path = require('path');
//日志目录
const LOG_DIR = path.join(__dirname, '../../logs');
//确保日志目录存在
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
function getTimestamp() {
const date = new Date();
const utc8Time = new Date(date.getTime() + (8 * 60 * 60 * 1000));
const year = utc8Time.getUTCFullYear();
const month = String(utc8Time.getUTCMonth() + 1).padStart(2, '0');
const day = String(utc8Time.getUTCDate()).padStart(2, '0');
const hours = String(utc8Time.getUTCHours()).padStart(2, '0');
const minutes = String(utc8Time.getUTCMinutes()).padStart(2, '0');
const seconds = String(utc8Time.getUTCSeconds()).padStart(2, '0');
const ms = String(utc8Time.getUTCMilliseconds()).padStart(3, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}`;
}
//获取日志文件名
function getLogFileName() {
const date = new Date();
const utc8Time = new Date(date.getTime() + (8 * 60 * 60 * 1000));
const year = utc8Time.getUTCFullYear();
const month = String(utc8Time.getUTCMonth() + 1).padStart(2, '0');
const day = String(utc8Time.getUTCDate()).padStart(2, '0');
return `sync-${year}-${month}-${day}.log`;
}
//写入日志到文件
function writeLog(level, message, data = null) {
const timestamp = getTimestamp();
const logFile = path.join(LOG_DIR, getLogFileName());
let logLine = `[${timestamp}] [${level}] ${message}`;
if (data) {
if (typeof data === 'object') {
logLine += ' ' + JSON.stringify(data);
} else {
logLine += ' ' + data;
}
}
logLine += '\n';
//异步写入,不阻塞主流程
fs.appendFile(logFile, logLine, (err) => {
if (err) {
console.error('[ERROR] Logger failed to write log:', err.message);
}
});
}
//同步写入只用于输出fatal日志不要用于其它场景以免阻塞主流程
function writeLogSync(level, message, data = null) {
const timestamp = getTimestamp();
const logFile = path.join(LOG_DIR, getLogFileName());
let logLine = `[${timestamp}] [${level}] ${message}`;
if (data) {
if (typeof data === 'object') {
logLine += ' ' + JSON.stringify(data);
} else {
logLine += ' ' + data;
}
}
logLine += '\n';
try {
fs.appendFileSync(logFile, logLine);
} catch (err) {
console.error('[ERROR] Logger failed to write log:', err.message);
}
}
//日志级别函数
const logger = {
info: (message, data) => {
console.log(`[INFO] ${message}`);
writeLog('INFO', message, data);
},
warn: (message, data) => {
console.warn(`[WARN] ${message}`);
writeLog('WARN', message, data);
},
error: (message, data) => {
console.error(`[ERROR] ${message}`);
writeLog('ERROR', message, data);
},
security: (message, data) => {
console.warn(`[SECURITY] ${message}`);
writeLog('SECURITY', message, data);
},
sync: (message, data) => {
console.log(`[SYNC] ${message}`);
writeLog('SYNC', message, data);
},
fatal: (message, data) => {
console.error(`[FATAL] ${message}`);
writeLogSync('FATAL', message, data);
},
//清理旧日志
cleanOldLogs: (daysToKeep = 30) => {
try {
const files = fs.readdirSync(LOG_DIR);
const now = Date.now();
const maxAge = daysToKeep * 24 * 60 * 60 * 1000;
files.forEach(file => {
const filePath = path.join(LOG_DIR, file);
const stats = fs.statSync(filePath);
const age = now - stats.mtime.getTime();
if (age > maxAge && file.endsWith('.log')) {
fs.unlinkSync(filePath);
logger.info(`Deleted old log file: ${file}`);
}
});
} catch (error) {
console.error('[ERROR] Logger error cleaning old logs:', error.message);
}
}
};
//启动时清理旧日志
logger.cleanOldLogs(config.app.logRetentionDays);
//定时清理(每天执行一次)
setInterval(() => {
logger.cleanOldLogs(config.app.logRetentionDays);
}, 24 * 60 * 60 * 1000);
module.exports = logger;

View File

@@ -0,0 +1,196 @@
/**
* 测试工单清理脚本
* 清理所有标题包含 [TEST] 的测试工单
*/
const axios = require('axios');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../../../.env') });
const GITEA_API = process.env.GITEA_BASE_URL;
const GITEA_TOKEN = process.env.GITEA_TOKEN;
const JIRA_API = process.env.JIRA_BASE_URL;
const JIRA_AUTH = process.env.JIRA_PAT
? { 'Authorization': `Bearer ${process.env.JIRA_PAT}` }
: { 'Authorization': `Basic ${Buffer.from(`${process.env.JIRA_USERNAME}:${process.env.JIRA_PASSWORD}`).toString('base64')}` };
const REPO = {
owner: 'loren',
repo: 'issueBotTest'
};
const JIRA_PROJECT_KEY = process.env.JIRA_PROJECT_KEY_1 || 'TEST';
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
cyan: '\x1b[36m'
};
function log(color, message) {
console.log(`${color}${message}${colors.reset}`);
}
async function cleanupGiteaIssues() {
log(colors.cyan, '\n[Gitea] 查找测试工单...');
try {
// 获取所有 open 状态的 issues
const openResponse = await axios.get(
`${GITEA_API}/repos/${REPO.owner}/${REPO.repo}/issues`,
{
headers: { 'Authorization': `token ${GITEA_TOKEN}` },
params: { state: 'open', per_page: 100 }
}
);
// 获取所有 closed 状态的 issues
const closedResponse = await axios.get(
`${GITEA_API}/repos/${REPO.owner}/${REPO.repo}/issues`,
{
headers: { 'Authorization': `token ${GITEA_TOKEN}` },
params: { state: 'closed', per_page: 100 }
}
);
const allIssues = [...openResponse.data, ...closedResponse.data];
const testIssues = allIssues.filter(issue =>
issue.title.includes('[TEST]') ||
issue.title.includes('测试') ||
issue.body?.includes('[自动化测试]')
);
log(colors.yellow, `找到 ${testIssues.length} 个测试工单`);
let closedCount = 0;
for (const issue of testIssues) {
try {
// 先打开(如果是关闭的)
if (issue.state === 'closed') {
await axios.patch(
`${GITEA_API}/repos/${REPO.owner}/${REPO.repo}/issues/${issue.number}`,
{ state: 'open' },
{ headers: { 'Authorization': `token ${GITEA_TOKEN}` } }
);
await new Promise(resolve => setTimeout(resolve, 50));
}
// 关闭工单
await axios.patch(
`${GITEA_API}/repos/${REPO.owner}/${REPO.repo}/issues/${issue.number}`,
{ state: 'closed' },
{ headers: { 'Authorization': `token ${GITEA_TOKEN}` } }
);
closedCount++;
log(colors.green, ` ✓ 关闭 #${issue.number}: ${issue.title}`);
// 避免过快请求
await new Promise(resolve => setTimeout(resolve, 50));
} catch (error) {
log(colors.red, ` ✗ 关闭失败 #${issue.number}: ${error.message}`);
}
}
log(colors.green, `\n[Gitea] 成功关闭 ${closedCount}/${testIssues.length} 个测试工单`);
return closedCount;
} catch (error) {
log(colors.red, `[Gitea] 清理失败: ${error.message}`);
return 0;
}
}
async function cleanupJiraIssues() {
log(colors.cyan, '\n[Jira] 查找测试工单...');
try {
// 使用 JQL 查询测试工单(使用 text ~ 进行全文搜索)
const jql = `project = ${JIRA_PROJECT_KEY} AND (text ~ "TEST" OR text ~ "测试" OR text ~ "自动化测试") ORDER BY created DESC`;
const searchResponse = await axios.get(
`${JIRA_API}/rest/api/2/search`,
{
headers: { ...JIRA_AUTH, 'Content-Type': 'application/json' },
params: { jql, maxResults: 200 }
}
);
const testIssues = searchResponse.data.issues || [];
log(colors.yellow, `找到 ${testIssues.length} 个测试工单`);
let closedCount = 0;
for (const issue of testIssues) {
try {
// 获取工单的转换选项
const transitionsResponse = await axios.get(
`${JIRA_API}/rest/api/2/issue/${issue.key}/transitions`,
{ headers: { ...JIRA_AUTH, 'Content-Type': 'application/json' } }
);
// 查找 "完成"、"Done"、"关闭" 等转换
const closeTransition = transitionsResponse.data.transitions.find(t =>
t.name === '完成' ||
t.name === 'Done' ||
t.name === '关闭' ||
t.to.name === 'Done' ||
t.to.statusCategory?.key === 'done'
);
if (closeTransition) {
await axios.post(
`${JIRA_API}/rest/api/2/issue/${issue.key}/transitions`,
{ transition: { id: closeTransition.id } },
{ headers: { ...JIRA_AUTH, 'Content-Type': 'application/json' } }
);
closedCount++;
log(colors.green, ` ✓ 关闭 ${issue.key}: ${issue.fields.summary}`);
} else {
log(colors.yellow, `${issue.key} 无可用的关闭转换`);
}
// 避免过快请求
await new Promise(resolve => setTimeout(resolve, 50));
} catch (error) {
log(colors.red, ` ✗ 关闭失败 ${issue.key}: ${error.message}`);
}
}
log(colors.green, `\n[Jira] 成功关闭 ${closedCount}/${testIssues.length} 个测试工单`);
return closedCount;
} catch (error) {
log(colors.red, `[Jira] 清理失败: ${error.message}`);
if (error.response) {
log(colors.red, ` 状态码: ${error.response.status}`);
log(colors.red, ` 响应: ${JSON.stringify(error.response.data)}`);
}
return 0;
}
}
async function main() {
log(colors.cyan, '========================================');
log(colors.cyan, ' 测试工单清理脚本');
log(colors.cyan, '========================================\n');
log(colors.yellow, '警告: 此脚本将关闭所有标题包含 [TEST] 或 "测试" 的工单');
log(colors.yellow, '按 Ctrl+C 取消,或等待 3 秒后开始...\n');
await new Promise(resolve => setTimeout(resolve, 3000));
const giteaClosed = await cleanupGiteaIssues();
const jiraClosed = await cleanupJiraIssues();
log(colors.cyan, '\n========================================');
log(colors.cyan, '清理完成');
log(colors.cyan, '========================================');
log(colors.green, `Gitea: 关闭 ${giteaClosed} 个工单`);
log(colors.green, `Jira: 关闭 ${jiraClosed} 个工单`);
}
main().catch(error => {
log(colors.red, `\n脚本执行失败: ${error.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,614 @@
const crypto = require('crypto');
const axios = require('axios');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../../../.env') });
// 配置 - 使用真实仓库
const BASE_URL = 'http://localhost:3000';
const WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET;
const JIRA_BASE_URL = process.env.JIRA_BASE_URL;
const GITEA_BASE_URL = process.env.GITEA_BASE_URL;
// 使用 mappings.js 中配置的真实仓库
const REAL_REPO = {
owner: 'loren',
repo: 'issueBotTest',
fullName: 'loren/issueBotTest'
};
const JIRA_PROJECT = {
key: process.env.JIRA_PROJECT_KEY_1 || 'TEST',
id: process.env.JIRA_PROJECT_ID_1 || '10000'
};
// 测试统计
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
const testResults = [];
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
magenta: '\x1b[35m',
gray: '\x1b[90m'
};
function log(color, message) {
console.log(`${color}${message}${colors.reset}`);
}
function createSignature(payload) {
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
return hmac.update(JSON.stringify(payload)).digest('hex');
}
async function sendGiteaWebhook(payload, signature = null, useSignature = true) {
const headers = {
'Content-Type': 'application/json',
'X-Gitea-Event': 'issues'
};
if (useSignature) {
headers['X-Gitea-Signature'] = signature || createSignature(payload);
}
try {
const response = await axios.post(`${BASE_URL}/hooks/gitea`, payload, {
headers,
timeout: 10000
});
return { success: true, status: response.status, data: response.data };
} catch (error) {
return {
success: false,
status: error.response?.status,
data: error.response?.data,
error: error.message
};
}
}
async function sendJiraWebhook(payload) {
try {
const response = await axios.post(`${BASE_URL}/hooks/jira`, payload, {
headers: { 'Content-Type': 'application/json' },
timeout: 10000
});
return { success: true, status: response.status, data: response.data };
} catch (error) {
return {
success: false,
status: error.response?.status,
data: error.response?.data,
error: error.message
};
}
}
function createGiteaPayload(action = 'opened', issueNumber = null, overrides = {}) {
const number = issueNumber || Math.floor(Math.random() * 100000);
return {
action,
sender: overrides.sender || { id: 1, username: REAL_REPO.owner },
issue: {
number,
title: overrides.title || `[测试] 工单 #${number}`,
body: overrides.body || '这是测试工单的描述内容',
state: action === 'closed' ? 'closed' : 'open',
labels: overrides.labels || [],
milestone: overrides.milestone || null,
assignee: overrides.assignee || null,
html_url: `${GITEA_BASE_URL}/${REAL_REPO.owner}/${REAL_REPO.repo}/issues/${number}`,
...overrides.issue
},
repository: {
name: REAL_REPO.repo,
owner: { username: REAL_REPO.owner },
...overrides.repository
},
comment: overrides.comment || null
};
}
function createJiraPayload(event = 'jira:issue_created', issueKey = null, overrides = {}) {
const key = issueKey || `${JIRA_PROJECT.key}-${Math.floor(Math.random() * 10000)}`;
return {
webhookEvent: event,
user: overrides.user || {
accountId: 'test-user-123',
displayName: 'Test User',
name: 'testuser'
},
issue: {
key,
id: Math.floor(Math.random() * 100000),
fields: {
summary: overrides.summary || `[测试] Jira 工单 ${key}`,
description: overrides.description || '这是从 Jira 创建的测试工单',
project: { key: JIRA_PROJECT.key, id: JIRA_PROJECT.id },
issuetype: { id: '10001', name: '故事' },
priority: { id: '3', name: 'Medium' },
status: {
name: '待办',
statusCategory: { key: 'new' }
},
assignee: overrides.assignee || null,
customfield_10105: overrides.sprint || null,
...overrides.fields
}
},
comment: overrides.comment || null,
changelog: overrides.changelog || null
};
}
async function test(category, name, expectations, fn) {
totalTests++;
const testId = `${category}.${totalTests}`;
log(colors.cyan, `\n${testId} ${name}`);
if (expectations) {
log(colors.gray, ' 预期结果:');
if (expectations.gitea) {
log(colors.gray, ` 📝 Gitea: ${expectations.gitea}`);
}
if (expectations.jira) {
log(colors.gray, ` 📋 Jira: ${expectations.jira}`);
}
if (expectations.logs) {
log(colors.gray, ` 📄 日志: ${expectations.logs}`);
}
}
try {
await fn();
passedTests++;
log(colors.green, ` ✓ 通过`);
testResults.push({ id: testId, name, status: 'PASS' });
return true;
} catch (error) {
failedTests++;
log(colors.red, ` ✗ 失败: ${error.message}`);
testResults.push({ id: testId, name, status: 'FAIL', error: error.message });
return false;
}
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ==================== 测试用例 ====================
async function runTests() {
log(colors.magenta, '\n' + '='.repeat(60));
log(colors.magenta, ' 🧪 Gitea-Jira 双向同步综合测试');
log(colors.magenta, '='.repeat(60));
log(colors.blue, `\n配置信息:`);
log(colors.cyan, ` 仓库: ${REAL_REPO.fullName}`);
log(colors.cyan, ` Jira 项目: ${JIRA_PROJECT.key}`);
log(colors.cyan, ` Webhook URL: ${BASE_URL}`);
// ========== 1. 安全性测试 ==========
log(colors.yellow, '\n\n📌 1. 安全性测试');
await test('SEC', '无签名请求被拒绝', {
logs: '应输出 "Request missing signature header"'
}, async () => {
const payload = createGiteaPayload('opened');
const result = await sendGiteaWebhook(payload, null, false);
assert(result.status === 401, `Expected 401, got ${result.status}`);
});
await test('SEC', '错误签名被拒绝', {
logs: '应输出 "Invalid signature detected"'
}, async () => {
const payload = createGiteaPayload('opened');
const result = await sendGiteaWebhook(payload, 'wrong_signature');
assert(result.status === 401, `Expected 401, got ${result.status}`);
});
await test('SEC', '缺失必要字段的payload被拒绝', {
logs: '应输出 "Invalid payload structure"'
}, async () => {
const payload = { action: 'opened' }; // 缺少 issue 和 repository
const sig = createSignature(payload);
const result = await sendGiteaWebhook(payload, sig);
assert(result.status === 400, `Expected 400, got ${result.status}`);
});
// ========== 2. Gitea -> Jira 基础功能测试 ==========
log(colors.yellow, '\n\n📌 2. Gitea → Jira 基础同步测试');
await test('G2J', '创建工单(无标签)', {
gitea: '工单 #XXX 已创建',
jira: `${JIRA_PROJECT.key} 项目创建工单,标题、描述同步,添加来源评论`,
logs: `[${REAL_REPO.fullName}] [GITEA->JIRA] Created ${JIRA_PROJECT.key}-XXX from #XXX`
}, async () => {
const payload = createGiteaPayload('opened', null, {
title: '[测试] 基础工单创建'
});
const result = await sendGiteaWebhook(payload);
assert(result.success && result.status === 200, 'Webhook should succeed');
await sleep(1000);
});
await test('G2J', '创建工单(带优先级标签)', {
gitea: '工单带 testhigh 标签',
jira: `创建 High 优先级工单`,
logs: `日志显示工单创建成功`
}, async () => {
const payload = createGiteaPayload('opened', null, {
title: '[测试] 高优先级工单',
labels: [{ name: 'testhigh' }]
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('G2J', '创建工单(带类型标签)', {
gitea: '工单带 testbug 标签',
jira: `创建 Bug 类型工单`,
logs: `日志显示工单创建成功`
}, async () => {
const payload = createGiteaPayload('opened', null, {
title: '[测试] Bug 类型工单',
labels: [{ name: 'testbug' }]
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('G2J', '创建工单(带里程碑)', {
gitea: '工单关联 v1.0.0 里程碑',
jira: `工单分配到 Sprint 37`,
logs: `日志显示工单创建成功`
}, async () => {
const payload = createGiteaPayload('opened', null, {
title: '[测试] 带里程碑的工单',
milestone: { title: 'v1.0.0' }
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
// ========== 3. Gitea -> Jira 状态同步测试 ==========
log(colors.yellow, '\n\n📌 3. Gitea → Jira 状态同步测试');
const issueNum = Math.floor(Math.random() * 100000);
await test('G2J', '创建后关闭工单', {
gitea: `关闭工单 #${issueNum}`,
jira: `对应 Jira 工单状态变为"完成"`,
logs: `[${REAL_REPO.fullName}] [GITEA->JIRA] Closed ${JIRA_PROJECT.key}-XXX`
}, async () => {
// 先创建
let payload = createGiteaPayload('opened', issueNum, {
title: '[测试] 状态同步工单'
});
await sendGiteaWebhook(payload);
await sleep(2000);
// 再关闭
payload = createGiteaPayload('closed', issueNum);
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Close webhook should succeed');
await sleep(1000);
});
await test('G2J', '重新打开工单', {
gitea: `重新打开工单 #${issueNum}`,
jira: `对应 Jira 工单状态变为"处理中"`,
logs: `[${REAL_REPO.fullName}] [GITEA->JIRA] Reopened ${JIRA_PROJECT.key}-XXX`
}, async () => {
const payload = createGiteaPayload('reopened', issueNum);
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Reopen webhook should succeed');
await sleep(1000);
});
// ========== 4. Jira -> Gitea 同步测试 ==========
log(colors.yellow, '\n\n📌 4. Jira → Gitea 反向同步测试');
await test('J2G', 'Jira 创建工单(需要手动创建或模拟)', {
jira: `${JIRA_PROJECT.key} 项目手动创建工单`,
gitea: `${REAL_REPO.fullName} 自动创建对应工单,带来源评论`,
logs: `[${REAL_REPO.fullName}] [JIRA->GITEA] Created #XXX from ${JIRA_PROJECT.key}-XXX`
}, async () => {
const payload = createJiraPayload('jira:issue_created');
const result = await sendJiraWebhook(payload);
assert(result.success, 'Jira webhook should succeed');
await sleep(1000);
});
await test('J2G', 'Jira 修改优先级', {
jira: `将工单优先级改为 High`,
gitea: `对应 Gitea 工单标签更新为 testhigh`,
logs: `[${REAL_REPO.fullName}] [JIRA->GITEA] Updated #XXX`
}, async () => {
const payload = createJiraPayload('jira:issue_updated', null, {
changelog: {
items: [{
field: 'priority',
fromString: 'Medium',
toString: 'High'
}]
},
fields: {
priority: { id: '2', name: 'High' }
}
});
const result = await sendJiraWebhook(payload);
assert(result.success, 'Priority change webhook should succeed');
await sleep(1000);
});
await test('J2G', 'Jira 修改类型', {
jira: `将工单类型改为 Bug`,
gitea: `对应 Gitea 工单标签更新为 testbug`,
logs: `[${REAL_REPO.fullName}] [JIRA->GITEA] Updated #XXX`
}, async () => {
const payload = createJiraPayload('jira:issue_updated', null, {
changelog: {
items: [{
field: 'issuetype',
fromString: '故事',
toString: 'Bug'
}]
},
fields: {
issuetype: { id: '10004', name: 'Bug' }
}
});
const result = await sendJiraWebhook(payload);
assert(result.success, 'Type change webhook should succeed');
await sleep(1000);
});
await test('J2G', 'Jira 修改 Sprint', {
jira: `将工单加入 Sprint 37`,
gitea: `对应 Gitea 工单里程碑设置为 v1.0.0`,
logs: `[${REAL_REPO.fullName}] [JIRA->GITEA] Updated #XXX`
}, async () => {
const payload = createJiraPayload('jira:issue_updated', null, {
changelog: {
items: [{
field: 'Sprint',
fromString: null,
toString: 'issueBot 1.0.0'
}]
},
fields: {
customfield_10105: [
'com.atlassian.greenhopper.service.sprint.Sprint@123[id=37,name=issueBot 1.0.0,state=ACTIVE]'
]
}
});
const result = await sendJiraWebhook(payload);
assert(result.success, 'Sprint change webhook should succeed');
await sleep(1000);
});
// ========== 5. 边界情况测试 ==========
log(colors.yellow, '\n\n📌 5. 边界情况测试');
await test('EDGE', '标题包含#不同步标记', {
gitea: '创建标题包含 #不同步 的工单',
jira: `不创建 Jira 工单`,
logs: `无同步日志`
}, async () => {
const payload = createGiteaPayload('opened', null, {
title: '测试工单 #不同步 测试'
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook accepted');
await sleep(500);
});
await test('EDGE', '超长标题500字符', {
gitea: '创建超长标题工单',
jira: `创建工单,标题可能被截断`,
logs: `工单创建成功`
}, async () => {
const longTitle = '[测试] ' + 'A'.repeat(500);
const payload = createGiteaPayload('opened', null, {
title: longTitle
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('EDGE', '超长描述5000字符', {
gitea: '创建超长描述工单',
jira: `创建工单,描述完整同步`,
logs: `工单创建成功`
}, async () => {
const longBody = '测试内容\n'.repeat(500);
const payload = createGiteaPayload('opened', null, {
body: longBody
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('EDGE', '空描述工单', {
gitea: '创建无描述工单',
jira: `创建工单,描述为空或"No description"`,
logs: `工单创建成功`
}, async () => {
const payload = createGiteaPayload('opened', null, {
body: ''
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('EDGE', '多个标签同时存在', {
gitea: '工单带多个标签(优先级+类型+其他)',
jira: `创建工单,正确映射优先级和类型,其他标签忽略`,
logs: `工单创建成功`
}, async () => {
const payload = createGiteaPayload('opened', null, {
labels: [
{ name: 'testhighest' },
{ name: 'testbug' },
{ name: 'enhancement' }
]
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook should succeed');
await sleep(1000);
});
await test('EDGE', '未配置的仓库', {
gitea: '向未配置的仓库发送 webhook',
jira: `不创建 Jira 工单`,
logs: `[unknown/repo] Repository not configured`
}, async () => {
const payload = createGiteaPayload('opened', null, {
repository: {
name: 'unknown-repo',
owner: { username: 'unknown-user' }
}
});
const result = await sendGiteaWebhook(payload);
assert(result.status === 500, 'Should return error');
});
// ========== 6. 并发和压力测试 ==========
log(colors.yellow, '\n\n📌 6. 并发和压力测试');
await test('STRESS', '连续快速创建10个工单', {
gitea: '快速创建10个工单',
jira: `创建10个对应工单`,
logs: `应该全部成功,但可能触发熔断器`
}, async () => {
const promises = [];
for (let i = 0; i < 10; i++) {
const payload = createGiteaPayload('opened', null, {
title: `[压力测试] 工单 ${i + 1}/10`
});
promises.push(sendGiteaWebhook(payload));
}
const results = await Promise.all(promises);
const successCount = results.filter(r => r.success).length;
log(colors.cyan, ` 成功: ${successCount}/10`);
assert(successCount >= 8, `Expected >=8 success, got ${successCount}`);
await sleep(2000);
});
await test('STRESS', '并发修改同一工单', {
gitea: '对同一工单发送多个修改请求',
jira: `工单更新成功,锁机制防止竞态`,
logs: `可能看到锁定和重试日志`
}, async () => {
const issueNum = Math.floor(Math.random() * 100000);
// 先创建
await sendGiteaWebhook(createGiteaPayload('opened', issueNum));
await sleep(1000);
// 并发修改
const promises = [];
for (let i = 0; i < 5; i++) {
const payload = createGiteaPayload('edited', issueNum, {
title: `[并发测试] 版本 ${i + 1}`
});
promises.push(sendGiteaWebhook(payload));
}
const results = await Promise.all(promises);
const successCount = results.filter(r => r.success).length;
assert(successCount >= 4, `Expected >=4 success, got ${successCount}`);
await sleep(1000);
});
// ========== 7. 机器人防死循环测试 ==========
log(colors.yellow, '\n\n📌 7. 机器人防死循环测试');
await test('BOT', 'Gitea 机器人操作被忽略', {
gitea: '机器人账号创建工单',
jira: `不创建 Jira 工单`,
logs: `无同步日志(被静默忽略)`
}, async () => {
const payload = createGiteaPayload('opened', null, {
sender: {
id: parseInt(process.env.GITEA_BOT_ID || '0'),
username: process.env.GITEA_BOT_NAME || 'issuebot'
}
});
const result = await sendGiteaWebhook(payload);
assert(result.success, 'Webhook accepted but ignored');
await sleep(500);
});
await test('BOT', 'Jira 机器人操作被忽略', {
jira: '机器人账号创建工单',
gitea: `不创建 Gitea 工单`,
logs: `无同步日志(被静默忽略)`
}, async () => {
const payload = createJiraPayload('jira:issue_created', null, {
user: {
accountId: process.env.JIRA_BOT_ID || 'bot-id',
displayName: 'Issue Bot',
name: process.env.JIRA_BOT_NAME || 'issuebot'
}
});
const result = await sendJiraWebhook(payload);
assert(result.success, 'Webhook accepted but ignored');
await sleep(500);
});
// ========== 测试总结 ==========
log(colors.magenta, '\n\n' + '='.repeat(60));
log(colors.magenta, ' 📊 测试结果汇总');
log(colors.magenta, '='.repeat(60));
const passRate = ((passedTests / totalTests) * 100).toFixed(1);
log(colors.cyan, `\n总计: ${totalTests} 个测试`);
log(colors.green, `✓ 通过: ${passedTests} (${passRate}%)`);
log(colors.red, `✗ 失败: ${failedTests} (${(100 - passRate).toFixed(1)}%)`);
if (failedTests > 0) {
log(colors.red, '\n失败的测试:');
testResults
.filter(r => r.status === 'FAIL')
.forEach(r => {
log(colors.red, ` ${r.id} ${r.name}: ${r.error}`);
});
}
log(colors.blue, `\n💡 提示:`);
log(colors.cyan, ` 1. 检查 logs/sync-${new Date().toISOString().split('T')[0]}.log 查看详细日志`);
log(colors.cyan, ` 2. 手动验证 Jira 项目 ${JIRA_PROJECT.key} 和 Gitea 仓库 ${REAL_REPO.fullName}`);
log(colors.cyan, ` 3. Jira→Gitea 测试需要确保工单已建立映射关系`);
log(colors.cyan, ` 4. 压力测试可能触发熔断器10秒内>20请求`);
log(colors.magenta, '\n' + '='.repeat(60) + '\n');
process.exit(failedTests > 0 ? 1 : 0);
}
// 运行测试
runTests().catch(error => {
log(colors.red, `\n测试执行出错: ${error.message}`);
process.exit(1);
});