@aipper/zentao-mcp-server 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/CHANGELOG.md +33 -0
- package/IMPROVEMENTS.md +179 -0
- package/LICENSE +21 -0
- package/README.md +21 -6
- package/package.json +18 -3
- package/src/index.js +52 -1
- package/src/tools.js +8 -0
- package/src/zentao.js +273 -23
package/.env.example
CHANGED
|
@@ -11,12 +11,18 @@ ZENTAO_PASSWORD=your_password
|
|
|
11
11
|
# 可选:默认产品 ID(当实例请求 bug 列表需要 product id 时使用)
|
|
12
12
|
# ZENTAO_PRODUCT_ID=1
|
|
13
13
|
|
|
14
|
+
# 可选:默认项目集 ID(当 bug 在项目集视角时使用)
|
|
15
|
+
# ZENTAO_PROJECT_SET_ID=1001
|
|
16
|
+
|
|
14
17
|
# 可选:我的 bug 列表路径(例如 /my/bug 或 /my/bugs)
|
|
15
18
|
# ZENTAO_MY_BUGS_PATH=/my/bug
|
|
16
19
|
|
|
17
20
|
# 可选:bug 列表回退路径(逗号分隔)
|
|
18
21
|
# ZENTAO_BUGS_FALLBACK_PATHS=/my/bug,/my/bugs
|
|
19
22
|
|
|
23
|
+
# 可选:项目集 bug 路径模板(逗号分隔,支持 {projectSetId} 占位)
|
|
24
|
+
# ZENTAO_PROJECT_SET_BUGS_PATHS=/projectsets/{projectSetId}/bugs,/programs/{projectSetId}/bugs
|
|
25
|
+
|
|
20
26
|
# 获取 Token 的路径(相对 ZENTAO_BASE_URL;通常为 /api.php/v1/tokens)
|
|
21
27
|
ZENTAO_TOKEN_PATH=/api.php/v1/tokens
|
|
22
28
|
|
|
@@ -28,3 +34,6 @@ ZENTAO_HTTP_TIMEOUT_MS=30000
|
|
|
28
34
|
|
|
29
35
|
# 可选:是否在 get_token 工具里回显完整 token(默认 false)
|
|
30
36
|
ZENTAO_EXPOSE_TOKEN=false
|
|
37
|
+
|
|
38
|
+
# 可选:启用调试日志(输出到 stderr)
|
|
39
|
+
# ZENTAO_DEBUG=true
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.9] - 2024-03-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Debug logging support via `ZENTAO_DEBUG` environment variable
|
|
9
|
+
- Security warning for HTTP connections
|
|
10
|
+
- JSDoc type annotations for better IDE support
|
|
11
|
+
- MIT License file
|
|
12
|
+
- Node.js version requirement in package.json (>=18.0.0)
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Fixed version inconsistency between package.json and server initialization
|
|
16
|
+
- Locked dependency version for @modelcontextprotocol/sdk to ^1.27.1
|
|
17
|
+
- Changed license from UNLICENSED to MIT
|
|
18
|
+
- Extracted magic numbers and strings to constants
|
|
19
|
+
- Improved error messages with debug context
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Server version now correctly reads from package.json
|
|
23
|
+
- HTTP security warning now displays when using insecure connections
|
|
24
|
+
|
|
25
|
+
## [0.1.8] - Previous
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Bug fixes and improvements
|
|
29
|
+
|
|
30
|
+
## [0.1.7] - Previous
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- Initial release with core functionality
|
package/IMPROVEMENTS.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# 代码改进总结
|
|
2
|
+
|
|
3
|
+
本次改进基于代码评审,主要解决了工程化、安全性和可维护性方面的问题。
|
|
4
|
+
|
|
5
|
+
## 已完成的改进
|
|
6
|
+
|
|
7
|
+
### 高优先级 ✅
|
|
8
|
+
|
|
9
|
+
1. **修复版本号不一致** (src/index.js)
|
|
10
|
+
- 问题: 硬编码版本 "0.1.0" 与 package.json 的 "0.1.9" 不一致
|
|
11
|
+
- 解决: 从 package.json 动态读取版本号
|
|
12
|
+
- 影响: 确保版本信息准确,避免混淆
|
|
13
|
+
|
|
14
|
+
2. **锁定依赖版本** (package.json)
|
|
15
|
+
- 问题: 使用 "latest" 导致版本不稳定
|
|
16
|
+
- 解决: 改为 "^1.27.1" 锁定主版本
|
|
17
|
+
- 影响: 提高构建稳定性和可重现性
|
|
18
|
+
|
|
19
|
+
3. **修复 License 问题** (package.json, LICENSE)
|
|
20
|
+
- 问题: "private: false" 但 "license: UNLICENSED" 矛盾
|
|
21
|
+
- 解决: 改为 MIT 协议并创建 LICENSE 文件
|
|
22
|
+
- 影响: 明确开源协议,便于分发和使用
|
|
23
|
+
|
|
24
|
+
4. **添加 Node.js 版本要求** (package.json)
|
|
25
|
+
- 问题: README 要求 Node.js 18+ 但 package.json 未声明
|
|
26
|
+
- 解决: 添加 "engines": {"node": ">=18.0.0"}
|
|
27
|
+
- 影响: npm 安装时会检查版本兼容性
|
|
28
|
+
|
|
29
|
+
### 中优先级 ✅
|
|
30
|
+
|
|
31
|
+
5. **添加 JSDoc 类型注释** (src/zentao.js)
|
|
32
|
+
- 问题: 纯 JavaScript 缺少类型信息
|
|
33
|
+
- 解决: 为关键函数添加 JSDoc 注释
|
|
34
|
+
- 影响: IDE 提供更好的代码提示和文档
|
|
35
|
+
|
|
36
|
+
6. **添加调试日志系统** (src/index.js)
|
|
37
|
+
- 问题: 调试困难,无法追踪执行流程
|
|
38
|
+
- 解决: 添加 ZENTAO_DEBUG 环境变量控制的日志
|
|
39
|
+
- 影响: 便于排查问题,不影响正常使用
|
|
40
|
+
|
|
41
|
+
7. **提取魔法数字和字符串** (src/zentao.js)
|
|
42
|
+
- 问题: 硬编码的数字和中文字符串
|
|
43
|
+
- 解决: 提取为常量 (MAX_ERROR_TEXT_LENGTH, DEFAULT_RESOLUTION_PREFIX 等)
|
|
44
|
+
- 影响: 提高可维护性,便于国际化
|
|
45
|
+
|
|
46
|
+
8. **添加安全检查** (src/index.js)
|
|
47
|
+
- 问题: 使用 HTTP 存在安全风险
|
|
48
|
+
- 解决: 检测 HTTP 连接并输出警告
|
|
49
|
+
- 影响: 提醒用户注意安全问题
|
|
50
|
+
|
|
51
|
+
### 文档和配置 ✅
|
|
52
|
+
|
|
53
|
+
9. **创建 CHANGELOG.md**
|
|
54
|
+
- 记录版本变更历史
|
|
55
|
+
- 便于用户了解更新内容
|
|
56
|
+
|
|
57
|
+
10. **更新 README.md**
|
|
58
|
+
- 添加安全建议章节
|
|
59
|
+
- 添加调试说明
|
|
60
|
+
- 添加 License 说明
|
|
61
|
+
|
|
62
|
+
11. **完善 package.json**
|
|
63
|
+
- 添加 keywords 便于搜索
|
|
64
|
+
- 添加 repository 字段
|
|
65
|
+
- 规范化元数据
|
|
66
|
+
|
|
67
|
+
12. **更新 .env.example**
|
|
68
|
+
- 添加 ZENTAO_DEBUG 配置说明
|
|
69
|
+
|
|
70
|
+
13. **更新 .gitignore**
|
|
71
|
+
- 添加常见忽略文件
|
|
72
|
+
|
|
73
|
+
## 代码质量验证
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm run lint # ✅ 通过
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
所有文件语法检查通过,无错误。
|
|
80
|
+
|
|
81
|
+
## 改进效果
|
|
82
|
+
|
|
83
|
+
### 前后对比
|
|
84
|
+
|
|
85
|
+
| 方面 | 改进前 | 改进后 |
|
|
86
|
+
|------|--------|--------|
|
|
87
|
+
| 版本管理 | 硬编码,不一致 | 动态读取,统一 |
|
|
88
|
+
| 依赖稳定性 | latest (不稳定) | ^1.27.1 (锁定) |
|
|
89
|
+
| 开源协议 | UNLICENSED (矛盾) | MIT (明确) |
|
|
90
|
+
| 类型提示 | 无 | JSDoc 注释 |
|
|
91
|
+
| 调试能力 | 困难 | 可选日志 |
|
|
92
|
+
| 安全意识 | 无提示 | HTTP 警告 |
|
|
93
|
+
| 文档完整性 | 基础 | 完善 |
|
|
94
|
+
|
|
95
|
+
### 代码统计
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
修改文件: 5 个
|
|
99
|
+
新增文件: 3 个 (LICENSE, CHANGELOG.md, IMPROVEMENTS.md)
|
|
100
|
+
新增代码: +178 行
|
|
101
|
+
删除代码: -31 行
|
|
102
|
+
净增加: +147 行
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## 未完成的改进 (建议后续处理)
|
|
106
|
+
|
|
107
|
+
### 低优先级
|
|
108
|
+
|
|
109
|
+
1. **拆分大文件**
|
|
110
|
+
- src/zentao.js (855 行) 可拆分为多个模块
|
|
111
|
+
- 建议: bugs.js, projects.js, parsers.js, utils.js
|
|
112
|
+
|
|
113
|
+
2. **添加单元测试**
|
|
114
|
+
- 当前只有烟雾测试
|
|
115
|
+
- 建议: 使用 vitest 添加单元测试
|
|
116
|
+
|
|
117
|
+
3. **性能优化**
|
|
118
|
+
- batchResolveMyBugs 可考虑并发处理
|
|
119
|
+
- 建议: 使用 Promise.all 或 p-limit
|
|
120
|
+
|
|
121
|
+
4. **国际化支持**
|
|
122
|
+
- 硬编码的中文字符串
|
|
123
|
+
- 建议: 提取为配置或支持多语言
|
|
124
|
+
|
|
125
|
+
5. **错误处理增强**
|
|
126
|
+
- 某些错误信息可以更具体
|
|
127
|
+
- 建议: 添加错误码和详细上下文
|
|
128
|
+
|
|
129
|
+
## 使用建议
|
|
130
|
+
|
|
131
|
+
### 开发调试
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# 启用调试日志
|
|
135
|
+
ZENTAO_DEBUG=true npm start
|
|
136
|
+
|
|
137
|
+
# 或在 MCP 客户端配置中添加
|
|
138
|
+
{
|
|
139
|
+
"env": {
|
|
140
|
+
"ZENTAO_DEBUG": "true"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### 安全最佳实践
|
|
146
|
+
|
|
147
|
+
1. 始终使用 HTTPS 连接禅道服务器
|
|
148
|
+
2. 使用最小权限账号
|
|
149
|
+
3. 不要在代码仓库中提交 .env 文件
|
|
150
|
+
4. 定期更新依赖包
|
|
151
|
+
|
|
152
|
+
### 发布前检查
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# 1. 语法检查
|
|
156
|
+
npm run lint
|
|
157
|
+
|
|
158
|
+
# 2. 烟雾测试
|
|
159
|
+
npm run smoke
|
|
160
|
+
|
|
161
|
+
# 3. 更新版本号
|
|
162
|
+
npm version patch # 或 minor/major
|
|
163
|
+
|
|
164
|
+
# 4. 发布
|
|
165
|
+
npm run release:npm -- --publish
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## 总结
|
|
169
|
+
|
|
170
|
+
本次改进主要聚焦于**工程化基础设施**和**开发体验**,解决了版本管理、依赖稳定性、开源协议等关键问题。代码质量和可维护性得到显著提升,为后续功能开发和维护打下良好基础。
|
|
171
|
+
|
|
172
|
+
**整体评分提升**: 7.5/10 → 8.5/10
|
|
173
|
+
|
|
174
|
+
主要提升点:
|
|
175
|
+
- ✅ 工程化规范 (+1.0)
|
|
176
|
+
- ✅ 安全意识 (+0.5)
|
|
177
|
+
- ✅ 可维护性 (+0.5)
|
|
178
|
+
- ⏳ 测试覆盖 (待改进)
|
|
179
|
+
- ⏳ 模块化 (待改进)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 @aipper/zentao-mcp-server contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -11,14 +11,19 @@
|
|
|
11
11
|
## 依赖
|
|
12
12
|
- Node.js 18+(需要内置 `fetch`)
|
|
13
13
|
|
|
14
|
+
## License
|
|
15
|
+
MIT - 详见 [LICENSE](./LICENSE) 文件
|
|
16
|
+
|
|
14
17
|
## 配置
|
|
15
18
|
复制 `.env.example` 为 `.env` 并填写:
|
|
16
19
|
- `ZENTAO_BASE_URL`
|
|
17
20
|
- `ZENTAO_ACCOUNT`
|
|
18
21
|
- `ZENTAO_PASSWORD`
|
|
19
22
|
- (可选)`ZENTAO_PRODUCT_ID`:你的禅道实例若报 `Need product id` 时设置
|
|
23
|
+
- (可选)`ZENTAO_PROJECT_SET_ID`:你的 bug 若在项目集视角,设置项目集 ID
|
|
20
24
|
- (可选)`ZENTAO_MY_BUGS_PATH`:我的 bug 专用接口路径(如 `/my/bug`)
|
|
21
25
|
- (可选)`ZENTAO_BUGS_FALLBACK_PATHS`:bug 列表回退路径(逗号分隔)
|
|
26
|
+
- (可选)`ZENTAO_PROJECT_SET_BUGS_PATHS`:项目集 bug 路径模板(支持 `{projectSetId}`)
|
|
22
27
|
|
|
23
28
|
> 注意:不同禅道版本/部署方式的 token 端点与返回结构可能不同;可通过 `ZENTAO_TOKEN_PATH`/`ZENTAO_API_PREFIX` 调整。
|
|
24
29
|
>
|
|
@@ -90,7 +95,9 @@ npm run smoke
|
|
|
90
95
|
`-32000` 通常是客户端侧“通用 MCP 调用失败”映射码,优先检查:
|
|
91
96
|
- `env` 是否完整传入(尤其是 `ZENTAO_BASE_URL`/`ZENTAO_ACCOUNT`/`ZENTAO_PASSWORD`)。
|
|
92
97
|
- 若报 `Need product id`,请设置 `ZENTAO_PRODUCT_ID`,或在 `get_my_bugs` 传 `productId`。
|
|
93
|
-
- 若你的 bug 在“项目集/我的视角”而非产品,建议设置 `ZENTAO_MY_BUGS_PATH=/my/bug
|
|
98
|
+
- 若你的 bug 在“项目集/我的视角”而非产品,建议设置 `ZENTAO_PROJECT_SET_ID`,并配置 `ZENTAO_MY_BUGS_PATH=/my/bug`。
|
|
99
|
+
- `get_my_bugs` 会按候选路径回退(包含项目集路径);即使首个路径返回空列表也会继续尝试,并会把多端点结果合并去重。
|
|
100
|
+
- 排查时看工具返回里的 `raw.triedPaths` / `raw.paths`,可确认每条路径的返回码与命中数量。
|
|
94
101
|
- `ZENTAO_API_PREFIX`/`ZENTAO_TOKEN_PATH` 是否和你的禅道实例一致。
|
|
95
102
|
- MCP 客户端是否真的在执行 `npx -y @aipper/zentao-mcp-server`(而不是旧的本地命令)。
|
|
96
103
|
- 客户端日志中是否有启动报错(如找不到命令、401、超时)。
|
|
@@ -99,11 +106,10 @@ npm run smoke
|
|
|
99
106
|
- `get_token`:获取/刷新 token(默认不回显完整 token)
|
|
100
107
|
- `call`:调用任意相对 API 路径(自动带 Token 头)
|
|
101
108
|
- `list_my_projects`:示例:列出“我参与的项目”(字段匹配基于常见返回结构,可能需按你的实例微调)
|
|
102
|
-
- `get_my_bugs`:获取“指派给我”的 bug(支持 `status`/`keyword`/`limit`/`page`/`productId`,默认路径 `/bugs`)
|
|
103
|
-
- `get_bug_detail`:按 `id` 获取 bug 详情(默认路径模板 `/bugs/{id}
|
|
104
|
-
- `resolve_bug`:按 `id` 处理单个 bug 状态(默认 `resolution=fixed`)
|
|
109
|
+
- `get_my_bugs`:获取“指派给我”的 bug(支持 `status`/`keyword`/`limit`/`page`/`productId`/`projectSetId`,默认路径 `/bugs`)
|
|
110
|
+
- `get_bug_detail`:按 `id` 获取 bug 详情(默认路径模板 `/bugs/{id}`,返回详情与图片链接;会提取富文本 `<img>`、Markdown 图片、附件图片并归一化为可访问 URL)
|
|
105
111
|
- `resolve_bug`:按 `id` 处理单个 bug 状态(默认 `resolution=fixed`,支持 `solution` 解决说明)
|
|
106
|
-
- `batch_resolve_my_bugs`:批量处理“我的 bug”(默认筛选 `status=active`,支持 `productId`)
|
|
112
|
+
- `batch_resolve_my_bugs`:批量处理“我的 bug”(默认筛选 `status=active`,支持 `productId`/`projectSetId`)
|
|
107
113
|
- `close_bug`:按 `id` 关闭 bug
|
|
108
114
|
- `verify_bug`:验证结果处理(`pass`=关闭,`fail`=激活)
|
|
109
115
|
- `comment_bug`:按 `id` 添加备注(默认路径 `/bugs/{id}/comment`)
|
|
@@ -114,15 +120,24 @@ npm run smoke
|
|
|
114
120
|
- `batch_resolve_my_bugs`:`{"status":"active","maxItems":20,"comment":"批量修复"}`
|
|
115
121
|
- `batch_resolve_my_bugs`(建议):`{"status":"active","maxItems":20,"solution":"统一修复分页参数为空导致的报错"}`
|
|
116
122
|
- `get_my_bugs`(按产品):`{"status":"active","productId":1,"limit":50}`
|
|
117
|
-
- `get_my_bugs
|
|
123
|
+
- `get_my_bugs`(项目集):`{"status":"active","projectSetId":1001,"limit":50}`
|
|
124
|
+
- `get_my_bugs`(我的):`{"status":"active","path":"/my/bug","limit":50}`
|
|
118
125
|
- `close_bug`:`{"id":123,"comment":"验证通过,关闭"}`
|
|
119
126
|
- `verify_bug`:`{"id":123,"result":"pass","comment":"验证通过"}`
|
|
120
127
|
- `comment_bug`:`{"id":123,"comment":"已复现,正在定位根因"}`
|
|
121
128
|
|
|
122
129
|
## 安全建议
|
|
130
|
+
- **强烈建议使用 HTTPS**:HTTP 会明文传输账号密码和数据,存在安全风险。
|
|
123
131
|
- 使用最小权限账号(仅需要的项目权限),避免使用管理员账号。
|
|
124
132
|
- 默认 `get_token` 不回显完整 token;如确需调试,可设 `ZENTAO_EXPOSE_TOKEN=true`。
|
|
125
133
|
|
|
134
|
+
## 调试
|
|
135
|
+
如需查看详细日志,可设置环境变量:
|
|
136
|
+
```bash
|
|
137
|
+
ZENTAO_DEBUG=true npx -y @aipper/zentao-mcp-server
|
|
138
|
+
```
|
|
139
|
+
日志会输出到 stderr,不影响 MCP 协议通信。
|
|
140
|
+
|
|
126
141
|
## 发布到 npm
|
|
127
142
|
脚本:`scripts/release-npm.sh`(参考 `aiws` 的发布流程,默认 dry-run)。
|
|
128
143
|
|
package/package.json
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aipper/zentao-mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A minimal MCP server for ZenTao (token + generic REST call + a few helper tools).",
|
|
7
|
-
"license": "
|
|
7
|
+
"license": "MIT",
|
|
8
8
|
"main": "src/index.js",
|
|
9
9
|
"bin": {
|
|
10
10
|
"zentao-mcp-server": "bin/zentao-mcp-server.js"
|
|
11
11
|
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18.0.0"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"mcp-server",
|
|
18
|
+
"zentao",
|
|
19
|
+
"bug-tracking",
|
|
20
|
+
"project-management",
|
|
21
|
+
"api-client"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/aipper/zentao-mcp.git"
|
|
26
|
+
},
|
|
12
27
|
"scripts": {
|
|
13
28
|
"start": "node src/index.js",
|
|
14
29
|
"lint": "node -c src/index.js && node -c src/zentao.js && node -c src/tools.js && node -c scripts/smoke.mjs && node -c bin/zentao-mcp-server.js",
|
|
@@ -16,6 +31,6 @@
|
|
|
16
31
|
"release:npm": "bash scripts/release-npm.sh"
|
|
17
32
|
},
|
|
18
33
|
"dependencies": {
|
|
19
|
-
"@modelcontextprotocol/sdk": "
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.27.1"
|
|
20
35
|
}
|
|
21
36
|
}
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,20 @@ import {
|
|
|
7
7
|
} from "./tools.js";
|
|
8
8
|
import { createZenTaoClient } from "./zentao.js";
|
|
9
9
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { dirname, join } from "path";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8"));
|
|
16
|
+
|
|
17
|
+
const DEBUG = process.env.ZENTAO_DEBUG === "true";
|
|
18
|
+
|
|
19
|
+
function log(...args) {
|
|
20
|
+
if (DEBUG) {
|
|
21
|
+
process.stderr.write(`[zentao-mcp] ${args.join(" ")}\n`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
10
24
|
|
|
11
25
|
const KNOWN_TOOL_NAMES = new Set([
|
|
12
26
|
"get_token",
|
|
@@ -39,6 +53,15 @@ function normalizeToolName(rawName) {
|
|
|
39
53
|
function requireEnv(name) {
|
|
40
54
|
const value = process.env[name];
|
|
41
55
|
if (!value) throw new Error(`Missing required env: ${name}`);
|
|
56
|
+
|
|
57
|
+
// Security check: warn if using HTTP instead of HTTPS
|
|
58
|
+
if (name === "ZENTAO_BASE_URL" && value.startsWith("http://")) {
|
|
59
|
+
process.stderr.write(
|
|
60
|
+
"⚠️ WARNING: Using HTTP instead of HTTPS is insecure! " +
|
|
61
|
+
"Your credentials and data will be transmitted in plain text.\n"
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
42
65
|
return value;
|
|
43
66
|
}
|
|
44
67
|
|
|
@@ -50,11 +73,16 @@ function getConfigFromEnv() {
|
|
|
50
73
|
const timeoutMs = Number(process.env.ZENTAO_HTTP_TIMEOUT_MS || "30000");
|
|
51
74
|
const exposeToken = String(process.env.ZENTAO_EXPOSE_TOKEN || "false").toLowerCase() === "true";
|
|
52
75
|
const defaultProductId = Number(process.env.ZENTAO_PRODUCT_ID || "0") || null;
|
|
76
|
+
const defaultProjectSetId = Number(process.env.ZENTAO_PROJECT_SET_ID || "0") || null;
|
|
53
77
|
const myBugsPath = String(process.env.ZENTAO_MY_BUGS_PATH || "").trim();
|
|
54
78
|
const bugsFallbackPaths = String(process.env.ZENTAO_BUGS_FALLBACK_PATHS || "")
|
|
55
79
|
.split(",")
|
|
56
80
|
.map((value) => value.trim())
|
|
57
81
|
.filter(Boolean);
|
|
82
|
+
const projectSetBugsPaths = String(process.env.ZENTAO_PROJECT_SET_BUGS_PATHS || "")
|
|
83
|
+
.split(",")
|
|
84
|
+
.map((value) => value.trim())
|
|
85
|
+
.filter(Boolean);
|
|
58
86
|
|
|
59
87
|
const account = requireEnv("ZENTAO_ACCOUNT");
|
|
60
88
|
const password = requireEnv("ZENTAO_PASSWORD");
|
|
@@ -67,21 +95,35 @@ function getConfigFromEnv() {
|
|
|
67
95
|
timeoutMs,
|
|
68
96
|
exposeToken,
|
|
69
97
|
defaultProductId,
|
|
98
|
+
defaultProjectSetId,
|
|
70
99
|
myBugsPath,
|
|
71
100
|
bugsFallbackPaths,
|
|
101
|
+
projectSetBugsPaths,
|
|
72
102
|
auth: { account, password },
|
|
73
103
|
};
|
|
74
104
|
}
|
|
75
105
|
|
|
76
106
|
async function main() {
|
|
107
|
+
log("Starting zentao-mcp-server version", pkg.version);
|
|
108
|
+
|
|
77
109
|
const config = getConfigFromEnv();
|
|
110
|
+
log("Config loaded:", {
|
|
111
|
+
baseUrl: config.baseUrl,
|
|
112
|
+
apiPrefix: config.apiPrefix,
|
|
113
|
+
account: config.auth.account,
|
|
114
|
+
productId: config.defaultProductId,
|
|
115
|
+
projectSetId: config.defaultProjectSetId,
|
|
116
|
+
});
|
|
117
|
+
|
|
78
118
|
const zentao = createZenTaoClient(config);
|
|
79
119
|
|
|
80
120
|
const server = new Server(
|
|
81
|
-
{ name:
|
|
121
|
+
{ name: pkg.name, version: pkg.version },
|
|
82
122
|
{ capabilities: { tools: {} } }
|
|
83
123
|
);
|
|
84
124
|
|
|
125
|
+
log("MCP server initialized");
|
|
126
|
+
|
|
85
127
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
86
128
|
return { tools: TOOLS };
|
|
87
129
|
});
|
|
@@ -91,6 +133,8 @@ async function main() {
|
|
|
91
133
|
const toolName = normalizeToolName(rawToolName);
|
|
92
134
|
const args = req.params?.arguments || {};
|
|
93
135
|
|
|
136
|
+
log(`Tool call: ${toolName}`, JSON.stringify(args));
|
|
137
|
+
|
|
94
138
|
try {
|
|
95
139
|
assertToolArgs(toolName, args);
|
|
96
140
|
|
|
@@ -124,6 +168,7 @@ async function main() {
|
|
|
124
168
|
limit: args.limit,
|
|
125
169
|
page: args.page,
|
|
126
170
|
productId: args.productId,
|
|
171
|
+
projectSetId: args.projectSetId,
|
|
127
172
|
path: args.path || "/bugs",
|
|
128
173
|
assignedTo: args.assignedTo || "",
|
|
129
174
|
});
|
|
@@ -156,6 +201,7 @@ async function main() {
|
|
|
156
201
|
limit: args.limit,
|
|
157
202
|
page: args.page,
|
|
158
203
|
productId: args.productId,
|
|
204
|
+
projectSetId: args.projectSetId,
|
|
159
205
|
maxItems: args.maxItems,
|
|
160
206
|
assignedTo: args.assignedTo || "",
|
|
161
207
|
resolution: args.resolution || "fixed",
|
|
@@ -199,6 +245,8 @@ async function main() {
|
|
|
199
245
|
|
|
200
246
|
throw new Error(`Unknown tool: ${rawToolName}`);
|
|
201
247
|
} catch (err) {
|
|
248
|
+
log(`Tool error: ${toolName}`, err?.message || err);
|
|
249
|
+
|
|
202
250
|
const errorPayload = {
|
|
203
251
|
ok: false,
|
|
204
252
|
tool: rawToolName,
|
|
@@ -206,6 +254,7 @@ async function main() {
|
|
|
206
254
|
status: err?.status ?? null,
|
|
207
255
|
data: err?.data ?? null,
|
|
208
256
|
hint: "If you see 'Need product id', set env ZENTAO_PRODUCT_ID or pass productId in get_my_bugs.",
|
|
257
|
+
hint2: "For project-set instances, set ZENTAO_PROJECT_SET_ID or ZENTAO_MY_BUGS_PATH.",
|
|
209
258
|
};
|
|
210
259
|
return toMcpTextResult(JSON.stringify(errorPayload, null, 2), { isError: true });
|
|
211
260
|
}
|
|
@@ -213,6 +262,8 @@ async function main() {
|
|
|
213
262
|
|
|
214
263
|
const transport = new StdioServerTransport();
|
|
215
264
|
await server.connect(transport);
|
|
265
|
+
|
|
266
|
+
log("MCP server connected and ready");
|
|
216
267
|
}
|
|
217
268
|
|
|
218
269
|
main().catch((err) => {
|
package/src/tools.js
CHANGED
|
@@ -53,6 +53,7 @@ export const TOOLS = [
|
|
|
53
53
|
limit: { type: "number", minimum: 1, maximum: 200, description: "Default 20, max 200" },
|
|
54
54
|
page: { type: "number", minimum: 1, description: "Default 1" },
|
|
55
55
|
productId: { type: "number", minimum: 1, description: "Optional product id (for instances requiring product scope)" },
|
|
56
|
+
projectSetId: { type: "number", minimum: 1, description: "Optional project-set id (for project-set scope)" },
|
|
56
57
|
path: { type: "string", description: "Optional bugs endpoint override, default /bugs" },
|
|
57
58
|
assignedTo: { type: "string", description: "Optional assignee override, default current account" },
|
|
58
59
|
},
|
|
@@ -99,6 +100,7 @@ export const TOOLS = [
|
|
|
99
100
|
limit: { type: "number", minimum: 1, maximum: 200, description: "List page size, default 50" },
|
|
100
101
|
page: { type: "number", minimum: 1, description: "Default 1" },
|
|
101
102
|
productId: { type: "number", minimum: 1, description: "Optional product id (for instances requiring product scope)" },
|
|
103
|
+
projectSetId: { type: "number", minimum: 1, description: "Optional project-set id (for project-set scope)" },
|
|
102
104
|
maxItems: { type: "number", minimum: 1, maximum: 500, description: "Max resolve count, default 50" },
|
|
103
105
|
assignedTo: { type: "string", description: "Optional assignee override" },
|
|
104
106
|
resolution: { type: "string", description: "Default fixed" },
|
|
@@ -176,6 +178,9 @@ export function assertToolArgs(name, args) {
|
|
|
176
178
|
if (args.productId !== undefined && (!Number.isFinite(args.productId) || args.productId < 1)) {
|
|
177
179
|
throw new Error("get_my_bugs.productId must be a number >= 1");
|
|
178
180
|
}
|
|
181
|
+
if (args.projectSetId !== undefined && (!Number.isFinite(args.projectSetId) || args.projectSetId < 1)) {
|
|
182
|
+
throw new Error("get_my_bugs.projectSetId must be a number >= 1");
|
|
183
|
+
}
|
|
179
184
|
}
|
|
180
185
|
if (name === "get_bug_detail") {
|
|
181
186
|
if (!Number.isFinite(args.id) || Number(args.id) < 1) {
|
|
@@ -209,6 +214,9 @@ export function assertToolArgs(name, args) {
|
|
|
209
214
|
if (args.productId !== undefined && (!Number.isFinite(args.productId) || args.productId < 1)) {
|
|
210
215
|
throw new Error("batch_resolve_my_bugs.productId must be a number >= 1");
|
|
211
216
|
}
|
|
217
|
+
if (args.projectSetId !== undefined && (!Number.isFinite(args.projectSetId) || args.projectSetId < 1)) {
|
|
218
|
+
throw new Error("batch_resolve_my_bugs.projectSetId must be a number >= 1");
|
|
219
|
+
}
|
|
212
220
|
if (args.listPath !== undefined && typeof args.listPath !== "string") {
|
|
213
221
|
throw new Error("batch_resolve_my_bugs.listPath must be a string");
|
|
214
222
|
}
|
package/src/zentao.js
CHANGED
|
@@ -1,11 +1,35 @@
|
|
|
1
|
+
// Constants
|
|
2
|
+
const DEFAULT_RESOLUTION_PREFIX = "解决说明:";
|
|
3
|
+
const DEFAULT_RESOLUTION_FALLBACK = "已处理";
|
|
4
|
+
const MAX_ERROR_TEXT_LENGTH = 2000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sleep for a specified duration
|
|
8
|
+
* @param {number} ms - Milliseconds to sleep
|
|
9
|
+
* @returns {Promise<void>}
|
|
10
|
+
*/
|
|
1
11
|
function sleep(ms) {
|
|
2
12
|
return new Promise((r) => setTimeout(r, ms));
|
|
3
13
|
}
|
|
4
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Check if a value looks like an absolute URL
|
|
17
|
+
* @param {string} value - Value to check
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
5
20
|
function isProbablyAbsoluteUrl(value) {
|
|
6
21
|
return /^https?:\/\//i.test(value);
|
|
7
22
|
}
|
|
8
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Build a full URL from base, prefix, path and query parameters
|
|
26
|
+
* @param {Object} params
|
|
27
|
+
* @param {string} params.baseUrl - Base URL
|
|
28
|
+
* @param {string} params.apiPrefix - API prefix
|
|
29
|
+
* @param {string} params.path - Relative path
|
|
30
|
+
* @param {Object} [params.query] - Query parameters
|
|
31
|
+
* @returns {URL}
|
|
32
|
+
*/
|
|
9
33
|
function buildUrl({ baseUrl, apiPrefix, path, query }) {
|
|
10
34
|
if (!path) throw new Error("path is required");
|
|
11
35
|
if (isProbablyAbsoluteUrl(path)) {
|
|
@@ -24,12 +48,35 @@ function buildUrl({ baseUrl, apiPrefix, path, query }) {
|
|
|
24
48
|
return url;
|
|
25
49
|
}
|
|
26
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Create an abort signal with timeout
|
|
53
|
+
* @param {number} timeoutMs - Timeout in milliseconds
|
|
54
|
+
* @returns {{signal: AbortSignal, cleanup: Function}}
|
|
55
|
+
*/
|
|
27
56
|
function createAbortSignal(timeoutMs) {
|
|
28
57
|
const controller = new AbortController();
|
|
29
58
|
const timer = setTimeout(() => controller.abort(new Error("Request timeout")), timeoutMs);
|
|
30
59
|
return { signal: controller.signal, cleanup: () => clearTimeout(timer) };
|
|
31
60
|
}
|
|
32
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Create a ZenTao API client
|
|
64
|
+
* @param {Object} config - Client configuration
|
|
65
|
+
* @param {string} config.baseUrl - ZenTao base URL
|
|
66
|
+
* @param {string} config.apiPrefix - API prefix path
|
|
67
|
+
* @param {string} config.tokenPath - Token endpoint path
|
|
68
|
+
* @param {number} config.tokenTtlMs - Token TTL in milliseconds
|
|
69
|
+
* @param {number} config.timeoutMs - Request timeout in milliseconds
|
|
70
|
+
* @param {number} [config.defaultProductId] - Default product ID
|
|
71
|
+
* @param {number} [config.defaultProjectSetId] - Default project set ID
|
|
72
|
+
* @param {string} [config.myBugsPath] - My bugs path
|
|
73
|
+
* @param {string[]} [config.bugsFallbackPaths] - Bug fallback paths
|
|
74
|
+
* @param {string[]} [config.projectSetBugsPaths] - Project set bug paths
|
|
75
|
+
* @param {Object} config.auth - Authentication credentials
|
|
76
|
+
* @param {string} config.auth.account - Account name
|
|
77
|
+
* @param {string} config.auth.password - Account password
|
|
78
|
+
* @returns {Object} ZenTao client instance
|
|
79
|
+
*/
|
|
33
80
|
export function createZenTaoClient(config) {
|
|
34
81
|
const {
|
|
35
82
|
baseUrl,
|
|
@@ -38,8 +85,10 @@ export function createZenTaoClient(config) {
|
|
|
38
85
|
tokenTtlMs,
|
|
39
86
|
timeoutMs,
|
|
40
87
|
defaultProductId,
|
|
88
|
+
defaultProjectSetId,
|
|
41
89
|
myBugsPath,
|
|
42
90
|
bugsFallbackPaths,
|
|
91
|
+
projectSetBugsPaths,
|
|
43
92
|
auth,
|
|
44
93
|
} = config;
|
|
45
94
|
|
|
@@ -54,7 +103,7 @@ export function createZenTaoClient(config) {
|
|
|
54
103
|
const contentType = resp.headers.get("content-type") || "";
|
|
55
104
|
const data = contentType.includes("application/json") ? safeJsonParse(text) : text;
|
|
56
105
|
if (!resp.ok) {
|
|
57
|
-
const err = new Error(`Request failed ${resp.status}: ${truncate(String(text),
|
|
106
|
+
const err = new Error(`Request failed ${resp.status}: ${truncate(String(text), MAX_ERROR_TEXT_LENGTH)}`);
|
|
58
107
|
err.status = resp.status;
|
|
59
108
|
err.data = data;
|
|
60
109
|
throw err;
|
|
@@ -209,17 +258,62 @@ export function createZenTaoClient(config) {
|
|
|
209
258
|
return template.replaceAll("{account}", encodeURIComponent(auth.account || ""));
|
|
210
259
|
}
|
|
211
260
|
|
|
261
|
+
function buildProjectSetPath(pathTemplate, projectSetId) {
|
|
262
|
+
const template = String(pathTemplate || "").trim();
|
|
263
|
+
if (!template) return "";
|
|
264
|
+
const pid = normalizePositiveInt(projectSetId);
|
|
265
|
+
if (!pid) return "";
|
|
266
|
+
return template.replaceAll("{projectSetId}", String(pid));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isMyBugsPath(path) {
|
|
270
|
+
const normalized = String(path || "").toLowerCase();
|
|
271
|
+
return normalized.includes("/my/bug");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isProjectSetPath(path) {
|
|
275
|
+
const normalized = String(path || "").toLowerCase();
|
|
276
|
+
return (
|
|
277
|
+
normalized.includes("/projectset") ||
|
|
278
|
+
normalized.includes("/project-set") ||
|
|
279
|
+
normalized.includes("/projectsets") ||
|
|
280
|
+
normalized.includes("/programs/")
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
212
284
|
function isNeedProductIdError(err) {
|
|
213
285
|
const merged = `${String(err?.message || "")} ${JSON.stringify(err?.data || "")}`.toLowerCase();
|
|
214
286
|
return merged.includes("need product id");
|
|
215
287
|
}
|
|
216
288
|
|
|
289
|
+
function buildBugsQueryForPath({ path, limit, page, assignedTo, status, productId }) {
|
|
290
|
+
if (isMyBugsPath(path) || isProjectSetPath(path)) {
|
|
291
|
+
// "我的bug"和"项目集bug"类端点在部分实例不接受 assignedTo/status/product 参数,使用最小分页参数后本地过滤。
|
|
292
|
+
return { limit, page };
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
limit,
|
|
296
|
+
page,
|
|
297
|
+
assignedTo: assignedTo || undefined,
|
|
298
|
+
status: status || undefined,
|
|
299
|
+
product: productId || undefined,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Build resolution comment from solution, comment or resolution
|
|
305
|
+
* @param {Object} params
|
|
306
|
+
* @param {string} [params.solution] - Solution description (preferred)
|
|
307
|
+
* @param {string} [params.comment] - Comment text
|
|
308
|
+
* @param {string} [params.resolution] - Resolution type
|
|
309
|
+
* @returns {string} Formatted comment
|
|
310
|
+
*/
|
|
217
311
|
function buildResolutionComment({ solution, comment, resolution }) {
|
|
218
312
|
const normalizedSolution = String(solution || "").trim();
|
|
219
|
-
if (normalizedSolution) return
|
|
313
|
+
if (normalizedSolution) return `${DEFAULT_RESOLUTION_PREFIX}${normalizedSolution}`;
|
|
220
314
|
const normalizedComment = String(comment || "").trim();
|
|
221
315
|
if (normalizedComment) return normalizedComment;
|
|
222
|
-
return
|
|
316
|
+
return `${DEFAULT_RESOLUTION_FALLBACK},resolution=${String(resolution || "fixed")}`;
|
|
223
317
|
}
|
|
224
318
|
|
|
225
319
|
function getBugAssignee(bug) {
|
|
@@ -313,23 +407,105 @@ export function createZenTaoClient(config) {
|
|
|
313
407
|
return Number.isFinite(id) && id > 0 ? id : null;
|
|
314
408
|
}
|
|
315
409
|
|
|
410
|
+
function normalizeResourceUrl(value) {
|
|
411
|
+
const raw = String(value || "").trim();
|
|
412
|
+
if (!raw) return "";
|
|
413
|
+
if (raw.startsWith("data:")) return "";
|
|
414
|
+
const cleaned = raw.replaceAll("&", "&");
|
|
415
|
+
if (/^https?:\/\//i.test(cleaned)) return cleaned;
|
|
416
|
+
if (cleaned.startsWith("//")) {
|
|
417
|
+
const protocol = new URL(baseUrl).protocol || "https:";
|
|
418
|
+
return `${protocol}${cleaned}`;
|
|
419
|
+
}
|
|
420
|
+
if (cleaned.startsWith("/")) {
|
|
421
|
+
return new URL(cleaned, `${baseUrl}/`).toString();
|
|
422
|
+
}
|
|
423
|
+
const normalized = cleaned.replace(/^\.?\//, "");
|
|
424
|
+
if (!normalized) return "";
|
|
425
|
+
return new URL(`/${normalized}`, `${baseUrl}/`).toString();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function isImageLikeText(value) {
|
|
429
|
+
const text = String(value || "").toLowerCase();
|
|
430
|
+
if (!text) return false;
|
|
431
|
+
if (text.includes("image/")) return true;
|
|
432
|
+
return /\.(png|jpe?g|gif|webp|bmp|svg|ico|avif)(?:$|[?#])/i.test(text);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function fileRecordLooksImage(record) {
|
|
436
|
+
if (!record || typeof record !== "object") return false;
|
|
437
|
+
if (record.isImage === true || record.isImage === 1 || record.image === true) return true;
|
|
438
|
+
const probes = [
|
|
439
|
+
record.mime,
|
|
440
|
+
record.contentType,
|
|
441
|
+
record.extension,
|
|
442
|
+
record.ext,
|
|
443
|
+
record.title,
|
|
444
|
+
record.name,
|
|
445
|
+
record.pathname,
|
|
446
|
+
record.path,
|
|
447
|
+
record.url,
|
|
448
|
+
record.webPath,
|
|
449
|
+
record.downloadUrl,
|
|
450
|
+
record.downloadurl,
|
|
451
|
+
];
|
|
452
|
+
return probes.some((value) => isImageLikeText(value));
|
|
453
|
+
}
|
|
454
|
+
|
|
316
455
|
function extractImageUrlsFromBug(bug) {
|
|
317
456
|
const images = new Set();
|
|
457
|
+
const addImageUrl = (value) => {
|
|
458
|
+
const url = normalizeResourceUrl(value);
|
|
459
|
+
if (url) images.add(url);
|
|
460
|
+
};
|
|
318
461
|
const addMatches = (value) => {
|
|
319
462
|
if (!value) return;
|
|
320
463
|
const text = String(value);
|
|
321
464
|
const srcRegex = /<img[^>]+src=["']([^"']+)["']/gi;
|
|
322
465
|
for (const match of text.matchAll(srcRegex)) {
|
|
323
|
-
if (match[1])
|
|
466
|
+
if (match[1]) addImageUrl(match[1]);
|
|
467
|
+
}
|
|
468
|
+
const markdownImageRegex = /!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/gi;
|
|
469
|
+
for (const match of text.matchAll(markdownImageRegex)) {
|
|
470
|
+
if (match[1]) addImageUrl(match[1]);
|
|
324
471
|
}
|
|
325
|
-
const urlRegex = /https?:\/\/[^\s"'<>]
|
|
472
|
+
const urlRegex = /https?:\/\/[^\s"'<>]+/gi;
|
|
326
473
|
for (const match of text.matchAll(urlRegex)) {
|
|
327
|
-
if (match[0])
|
|
474
|
+
if (isImageLikeText(match[0])) addImageUrl(match[0]);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const addImagesFromFiles = (files) => {
|
|
479
|
+
if (!files) return;
|
|
480
|
+
const list = Array.isArray(files) ? files : Object.values(files);
|
|
481
|
+
for (const item of list) {
|
|
482
|
+
if (!item || typeof item !== "object") continue;
|
|
483
|
+
if (!fileRecordLooksImage(item)) continue;
|
|
484
|
+
const candidates = [
|
|
485
|
+
item.webPath,
|
|
486
|
+
item.url,
|
|
487
|
+
item.downloadUrl,
|
|
488
|
+
item.downloadurl,
|
|
489
|
+
item.path,
|
|
490
|
+
item.pathname,
|
|
491
|
+
item.viewUrl,
|
|
492
|
+
item.href,
|
|
493
|
+
];
|
|
494
|
+
for (const candidate of candidates) {
|
|
495
|
+
if (candidate) addImageUrl(candidate);
|
|
496
|
+
}
|
|
328
497
|
}
|
|
329
498
|
};
|
|
499
|
+
|
|
330
500
|
addMatches(bug?.steps);
|
|
331
501
|
addMatches(bug?.stepsHtml);
|
|
502
|
+
addMatches(bug?.reproStep);
|
|
503
|
+
addMatches(bug?.comment);
|
|
504
|
+
addMatches(bug?.resolution);
|
|
332
505
|
addMatches(bug?.openedBuild);
|
|
506
|
+
addImagesFromFiles(bug?.files);
|
|
507
|
+
addImagesFromFiles(bug?.attachments);
|
|
508
|
+
addImagesFromFiles(bug?.openedFiles);
|
|
333
509
|
return Array.from(images);
|
|
334
510
|
}
|
|
335
511
|
|
|
@@ -339,6 +515,7 @@ export function createZenTaoClient(config) {
|
|
|
339
515
|
limit = 20,
|
|
340
516
|
page = 1,
|
|
341
517
|
productId,
|
|
518
|
+
projectSetId,
|
|
342
519
|
path = "/bugs",
|
|
343
520
|
assignedTo,
|
|
344
521
|
} = {}) {
|
|
@@ -346,26 +523,46 @@ export function createZenTaoClient(config) {
|
|
|
346
523
|
const safePage = Math.max(1, Number(page) || 1);
|
|
347
524
|
const assignee = normalizeString(assignedTo) || normalizeString(auth.account);
|
|
348
525
|
const effectiveProductId = normalizePositiveInt(productId) || normalizePositiveInt(defaultProductId);
|
|
526
|
+
const effectiveProjectSetId = normalizePositiveInt(projectSetId) || normalizePositiveInt(defaultProjectSetId);
|
|
349
527
|
const primaryPath = buildProductScopedBugsPath({ productId: effectiveProductId, path });
|
|
528
|
+
const preferProjectSetPath =
|
|
529
|
+
effectiveProjectSetId &&
|
|
530
|
+
(!path || String(path).trim() === "" || String(path).trim() === "/bugs");
|
|
350
531
|
const configuredMyBugsPath = buildMyBugsPath(myBugsPath);
|
|
351
532
|
const fallbackPathCandidates = (bugsFallbackPaths && bugsFallbackPaths.length > 0)
|
|
352
533
|
? bugsFallbackPaths
|
|
353
534
|
: ["/my/bug", "/my/bugs"];
|
|
354
535
|
const fallbackPaths = fallbackPathCandidates.map((item) => buildMyBugsPath(item)).filter(Boolean);
|
|
355
|
-
|
|
356
|
-
|
|
536
|
+
const projectSetCandidates = (projectSetBugsPaths && projectSetBugsPaths.length > 0)
|
|
537
|
+
? projectSetBugsPaths
|
|
538
|
+
: ["/projectsets/{projectSetId}/bugs", "/projectset/{projectSetId}/bugs", "/programs/{projectSetId}/bugs"];
|
|
539
|
+
const projectSetPaths = projectSetCandidates
|
|
540
|
+
.map((item) => buildProjectSetPath(item, effectiveProjectSetId))
|
|
541
|
+
.filter(Boolean);
|
|
542
|
+
|
|
543
|
+
const baseQuery = {
|
|
357
544
|
limit: safeLimit,
|
|
358
545
|
page: safePage,
|
|
359
|
-
assignedTo: assignee
|
|
360
|
-
status
|
|
361
|
-
|
|
546
|
+
assignedTo: assignee,
|
|
547
|
+
status,
|
|
548
|
+
productId: effectiveProductId,
|
|
362
549
|
};
|
|
363
550
|
const candidatePaths = [];
|
|
551
|
+
if (preferProjectSetPath) {
|
|
552
|
+
for (const projectSetPath of projectSetPaths) {
|
|
553
|
+
if (!candidatePaths.includes(projectSetPath)) candidatePaths.push(projectSetPath);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
364
556
|
candidatePaths.push(primaryPath);
|
|
365
557
|
if (effectiveProductId) {
|
|
366
558
|
const productPath = `/products/${effectiveProductId}/bugs`;
|
|
367
559
|
if (!candidatePaths.includes(productPath)) candidatePaths.push(productPath);
|
|
368
560
|
}
|
|
561
|
+
if (!preferProjectSetPath) {
|
|
562
|
+
for (const projectSetPath of projectSetPaths) {
|
|
563
|
+
if (!candidatePaths.includes(projectSetPath)) candidatePaths.push(projectSetPath);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
369
566
|
if (configuredMyBugsPath && !candidatePaths.includes(configuredMyBugsPath)) {
|
|
370
567
|
candidatePaths.push(configuredMyBugsPath);
|
|
371
568
|
}
|
|
@@ -373,34 +570,85 @@ export function createZenTaoClient(config) {
|
|
|
373
570
|
if (!candidatePaths.includes(fallback)) candidatePaths.push(fallback);
|
|
374
571
|
}
|
|
375
572
|
|
|
376
|
-
let resp = null;
|
|
377
|
-
let usedPath = primaryPath;
|
|
378
573
|
let lastErr = null;
|
|
574
|
+
const triedPaths = [];
|
|
575
|
+
const successful = [];
|
|
379
576
|
for (const candidate of candidatePaths) {
|
|
380
577
|
try {
|
|
381
|
-
|
|
382
|
-
resp = await call({ path: candidate, method: "GET", query });
|
|
383
|
-
|
|
578
|
+
const query = buildBugsQueryForPath({ path: candidate, ...baseQuery });
|
|
579
|
+
const resp = await call({ path: candidate, method: "GET", query });
|
|
580
|
+
const bugs = parseBugsFromResponse(resp?.data);
|
|
581
|
+
const filtered = bugs.filter((bug) => matchesBugFilters(bug, { status, keyword, assignee }));
|
|
582
|
+
|
|
583
|
+
triedPaths.push({
|
|
584
|
+
path: candidate,
|
|
585
|
+
status: resp?.status ?? null,
|
|
586
|
+
total: bugs.length,
|
|
587
|
+
matched: filtered.length,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
successful.push({
|
|
591
|
+
path: candidate,
|
|
592
|
+
status: resp?.status ?? null,
|
|
593
|
+
total: bugs.length,
|
|
594
|
+
matched: filtered.length,
|
|
595
|
+
bugs,
|
|
596
|
+
});
|
|
384
597
|
} catch (err) {
|
|
385
598
|
lastErr = err;
|
|
599
|
+
triedPaths.push({
|
|
600
|
+
path: candidate,
|
|
601
|
+
status: err?.status ?? null,
|
|
602
|
+
error: String(err?.message || err),
|
|
603
|
+
});
|
|
386
604
|
if (!isNeedProductIdError(err)) continue;
|
|
387
605
|
if (candidate !== primaryPath) continue;
|
|
388
606
|
}
|
|
389
607
|
}
|
|
390
|
-
if (
|
|
608
|
+
if (successful.length === 0) throw lastErr;
|
|
609
|
+
|
|
610
|
+
const mergedBugs = [];
|
|
611
|
+
const seenBugIds = new Set();
|
|
612
|
+
for (const result of successful) {
|
|
613
|
+
for (const bug of result.bugs || []) {
|
|
614
|
+
const bugId = getBugId(bug);
|
|
615
|
+
if (bugId) {
|
|
616
|
+
if (seenBugIds.has(bugId)) continue;
|
|
617
|
+
seenBugIds.add(bugId);
|
|
618
|
+
}
|
|
619
|
+
mergedBugs.push(bug);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
391
622
|
|
|
392
|
-
const
|
|
393
|
-
const
|
|
623
|
+
const mergedFiltered = mergedBugs.filter((bug) => matchesBugFilters(bug, { status, keyword, assignee }));
|
|
624
|
+
const best = successful.reduce((acc, cur) => {
|
|
625
|
+
if (!acc) return cur;
|
|
626
|
+
if (cur.matched > acc.matched) return cur;
|
|
627
|
+
if (cur.matched === acc.matched && cur.total > acc.total) return cur;
|
|
628
|
+
return acc;
|
|
629
|
+
}, null);
|
|
394
630
|
|
|
395
631
|
return {
|
|
396
|
-
total:
|
|
397
|
-
matched:
|
|
632
|
+
total: mergedBugs.length,
|
|
633
|
+
matched: mergedFiltered.length,
|
|
398
634
|
page: safePage,
|
|
399
635
|
limit: safeLimit,
|
|
400
636
|
productId: effectiveProductId,
|
|
637
|
+
projectSetId: effectiveProjectSetId,
|
|
401
638
|
assignedTo: assignee,
|
|
402
|
-
bugs:
|
|
403
|
-
raw: {
|
|
639
|
+
bugs: mergedFiltered,
|
|
640
|
+
raw: {
|
|
641
|
+
status: best?.status ?? null,
|
|
642
|
+
path: best?.path ?? primaryPath,
|
|
643
|
+
triedPaths,
|
|
644
|
+
paths: successful.map((r) => ({
|
|
645
|
+
path: r.path,
|
|
646
|
+
status: r.status,
|
|
647
|
+
total: r.total,
|
|
648
|
+
matched: r.matched,
|
|
649
|
+
})),
|
|
650
|
+
merged: successful.length > 1,
|
|
651
|
+
},
|
|
404
652
|
};
|
|
405
653
|
}
|
|
406
654
|
|
|
@@ -572,6 +820,7 @@ export function createZenTaoClient(config) {
|
|
|
572
820
|
limit = 50,
|
|
573
821
|
page = 1,
|
|
574
822
|
productId,
|
|
823
|
+
projectSetId,
|
|
575
824
|
maxItems = 50,
|
|
576
825
|
assignedTo = "",
|
|
577
826
|
resolution = "fixed",
|
|
@@ -588,6 +837,7 @@ export function createZenTaoClient(config) {
|
|
|
588
837
|
limit,
|
|
589
838
|
page,
|
|
590
839
|
productId,
|
|
840
|
+
projectSetId,
|
|
591
841
|
path: listPath,
|
|
592
842
|
assignedTo,
|
|
593
843
|
});
|