init:taskbot 1.1.0
This commit is contained in:
42
.env_sample
Normal file
42
.env_sample
Normal 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
367
README.md
Normal 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
67
how-to-use.md
Normal 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
156
index.js
Normal 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
100
mappings.json
Normal 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是10004,story是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
1479
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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
400
public/dashboard-app.js
Normal 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
296
public/dashboard.html
Normal 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
268
public/editor.html
Normal 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
61
public/error.html
Normal 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
56
src/config/env.js
Normal 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
144
src/config/mappings.js
Normal 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
48
src/db/connection.js
Normal 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
33
src/db/issueMap.js
Normal 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
63
src/logic/converter.js
Normal 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 };
|
||||
452
src/logic/jiraSyncManager.js
Normal file
452
src/logic/jiraSyncManager.js
Normal 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
308
src/logic/syncManager.js
Normal 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
535
src/routes/editor.js
Normal 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
87
src/services/gitea.js
Normal 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
93
src/services/jira.js
Normal 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 };
|
||||
34
src/utils/circuitBreaker.js
Normal file
34
src/utils/circuitBreaker.js
Normal 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
149
src/utils/logger.js
Normal 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;
|
||||
196
src/utils/tests_created_by_claude/cleanup-test-issues.js
Normal file
196
src/utils/tests_created_by_claude/cleanup-test-issues.js
Normal 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);
|
||||
});
|
||||
614
src/utils/tests_created_by_claude/comprehensive-test.js
Normal file
614
src/utils/tests_created_by_claude/comprehensive-test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user