@aipper/zentao-mcp-server 0.1.4 → 0.1.6

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 CHANGED
@@ -5,8 +5,8 @@ ZENTAO_BASE_URL=http://zentao.example.com
5
5
  ZENTAO_ACCOUNT=your_account
6
6
  ZENTAO_PASSWORD=your_password
7
7
 
8
- # API 前缀(多数禅道为 /api.php/v1)
9
- ZENTAO_API_PREFIX=/api.php/v1
8
+ # 可选:API 前缀(默认 /api.php/v1)
9
+ # ZENTAO_API_PREFIX=/api.php/v1
10
10
 
11
11
  # 获取 Token 的路径(相对 ZENTAO_BASE_URL;通常为 /api.php/v1/tokens)
12
12
  ZENTAO_TOKEN_PATH=/api.php/v1/tokens
package/README.md CHANGED
@@ -18,6 +18,8 @@
18
18
  - `ZENTAO_PASSWORD`
19
19
 
20
20
  > 注意:不同禅道版本/部署方式的 token 端点与返回结构可能不同;可通过 `ZENTAO_TOKEN_PATH`/`ZENTAO_API_PREFIX` 调整。
21
+ >
22
+ > 默认情况下不需要配置 `ZENTAO_API_PREFIX`(默认值是 `/api.php/v1`)。
21
23
 
22
24
  ## 安装与运行
23
25
  ```bash
@@ -26,6 +28,12 @@ cp .env.example .env
26
28
  npm start
27
29
  ```
28
30
 
31
+ ## npm 安装后的运行
32
+ 发布到 npm 后,推荐用 `npx` 启动(适合 MCP 客户端配置):
33
+ ```bash
34
+ npx -y @aipper/zentao-mcp-server
35
+ ```
36
+
29
37
  ## 验证(不依赖 MCP 客户端)
30
38
  ```bash
31
39
  npm i
@@ -38,7 +46,24 @@ npm run smoke
38
46
  - 输出 `GET /projects status: 200`(或你的禅道实际返回码)
39
47
 
40
48
  ## Claude Desktop / Cursor 示例(stdio)
41
- 把启动命令指向 `node src/index.js`(或 `npm start`)。
49
+ 优先使用 `npx`(npm 发布版):
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "zentao": {
54
+ "command": "npx",
55
+ "args": ["-y", "@aipper/zentao-mcp-server"],
56
+ "env": {
57
+ "ZENTAO_BASE_URL": "https://zentao.example.com",
58
+ "ZENTAO_ACCOUNT": "your_account",
59
+ "ZENTAO_PASSWORD": "your_password"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ 本地源码调试可继续用 `node src/index.js`:
42
67
 
43
68
  示例(Claude Desktop 的 `mcpServers` 风格,按你的客户端实际字段为准):
44
69
  ```json
@@ -50,7 +75,6 @@ npm run smoke
50
75
  "cwd": "/ABS/PATH/TO/zentao",
51
76
  "env": {
52
77
  "ZENTAO_BASE_URL": "https://zentao.example.com",
53
- "ZENTAO_API_PREFIX": "/api.php/v1",
54
78
  "ZENTAO_ACCOUNT": "your_account",
55
79
  "ZENTAO_PASSWORD": "your_password"
56
80
  }
@@ -59,10 +83,29 @@ npm run smoke
59
83
  }
60
84
  ```
61
85
 
86
+ ## 常见错误(`-32000`)
87
+ `-32000` 通常是客户端侧“通用 MCP 调用失败”映射码,优先检查:
88
+ - `env` 是否完整传入(尤其是 `ZENTAO_BASE_URL`/`ZENTAO_ACCOUNT`/`ZENTAO_PASSWORD`)。
89
+ - `ZENTAO_API_PREFIX`/`ZENTAO_TOKEN_PATH` 是否和你的禅道实例一致。
90
+ - MCP 客户端是否真的在执行 `npx -y @aipper/zentao-mcp-server`(而不是旧的本地命令)。
91
+ - 客户端日志中是否有启动报错(如找不到命令、401、超时)。
92
+
62
93
  ## 已实现工具
63
94
  - `get_token`:获取/刷新 token(默认不回显完整 token)
64
95
  - `call`:调用任意相对 API 路径(自动带 Token 头)
65
96
  - `list_my_projects`:示例:列出“我参与的项目”(字段匹配基于常见返回结构,可能需按你的实例微调)
97
+ - `get_my_bugs`:获取“指派给我”的 bug(支持 `status`/`keyword`/`limit`/`page`,默认路径 `/bugs`)
98
+ - `get_bug_detail`:按 `id` 获取 bug 详情(默认路径模板 `/bugs/{id}`,返回详情与图片链接)
99
+ - `resolve_bug`:按 `id` 处理单个 bug 状态(默认 `resolution=fixed`)
100
+ - `batch_resolve_my_bugs`:批量处理“我的 bug”(默认筛选 `status=active`)
101
+ - `close_bug`:按 `id` 关闭 bug
102
+ - `verify_bug`:验证结果处理(`pass`=关闭,`fail`=激活)
103
+
104
+ 示例参数:
105
+ - `resolve_bug`:`{"id":123,"resolution":"fixed","comment":"已修复并自测"}`
106
+ - `batch_resolve_my_bugs`:`{"status":"active","maxItems":20,"comment":"批量修复"}`
107
+ - `close_bug`:`{"id":123,"comment":"验证通过,关闭"}`
108
+ - `verify_bug`:`{"id":123,"result":"pass","comment":"验证通过"}`
66
109
 
67
110
  ## 安全建议
68
111
  - 使用最小权限账号(仅需要的项目权限),避免使用管理员账号。
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import "../src/index.js";
3
+
package/package.json CHANGED
@@ -1,14 +1,17 @@
1
1
  {
2
2
  "name": "@aipper/zentao-mcp-server",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
7
  "license": "UNLICENSED",
8
8
  "main": "src/index.js",
9
+ "bin": {
10
+ "zentao-mcp-server": "bin/zentao-mcp-server.js"
11
+ },
9
12
  "scripts": {
10
13
  "start": "node src/index.js",
11
- "lint": "node -c src/index.js && node -c src/zentao.js && node -c src/tools.js && node -c scripts/smoke.mjs",
14
+ "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",
12
15
  "smoke": "node scripts/smoke.mjs",
13
16
  "release:npm": "bash scripts/release-npm.sh"
14
17
  },
package/src/index.js CHANGED
@@ -8,6 +8,33 @@ import {
8
8
  import { createZenTaoClient } from "./zentao.js";
9
9
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
10
10
 
11
+ const KNOWN_TOOL_NAMES = new Set([
12
+ "get_token",
13
+ "call",
14
+ "list_my_projects",
15
+ "get_my_bugs",
16
+ "get_bug_detail",
17
+ "resolve_bug",
18
+ "batch_resolve_my_bugs",
19
+ "close_bug",
20
+ "verify_bug",
21
+ ]);
22
+
23
+ function normalizeToolName(rawName) {
24
+ if (!rawName || typeof rawName !== "string") return rawName;
25
+ if (KNOWN_TOOL_NAMES.has(rawName)) return rawName;
26
+
27
+ if (rawName.includes("_")) {
28
+ const withoutPrefix = rawName.replace(/^[^_]+_/, "");
29
+ if (KNOWN_TOOL_NAMES.has(withoutPrefix)) return withoutPrefix;
30
+ }
31
+
32
+ for (const name of KNOWN_TOOL_NAMES) {
33
+ if (rawName.endsWith(name)) return name;
34
+ }
35
+ return rawName;
36
+ }
37
+
11
38
  function requireEnv(name) {
12
39
  const value = process.env[name];
13
40
  if (!value) throw new Error(`Missing required env: ${name}`);
@@ -50,7 +77,8 @@ async function main() {
50
77
  });
51
78
 
52
79
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
53
- const toolName = req.params?.name;
80
+ const rawToolName = req.params?.name;
81
+ const toolName = normalizeToolName(rawToolName);
54
82
  const args = req.params?.arguments || {};
55
83
  assertToolArgs(toolName, args);
56
84
 
@@ -77,7 +105,74 @@ async function main() {
77
105
  return toMcpTextResult(JSON.stringify(resp, null, 2));
78
106
  }
79
107
 
80
- throw new Error(`Unknown tool: ${toolName}`);
108
+ if (toolName === "get_my_bugs") {
109
+ const resp = await zentao.getMyBugs({
110
+ status: args.status || "",
111
+ keyword: args.keyword || "",
112
+ limit: args.limit,
113
+ page: args.page,
114
+ path: args.path || "/bugs",
115
+ assignedTo: args.assignedTo || "",
116
+ });
117
+ return toMcpTextResult(JSON.stringify(resp, null, 2));
118
+ }
119
+
120
+ if (toolName === "get_bug_detail") {
121
+ const resp = await zentao.getBugDetail({
122
+ id: args.id,
123
+ path: args.path || "/bugs/{id}",
124
+ });
125
+ return toMcpTextResult(JSON.stringify(resp, null, 2));
126
+ }
127
+
128
+ if (toolName === "resolve_bug") {
129
+ const resp = await zentao.resolveBug({
130
+ id: args.id,
131
+ resolution: args.resolution || "fixed",
132
+ comment: args.comment || "",
133
+ path: args.path || "/bugs/{id}/resolve",
134
+ });
135
+ return toMcpTextResult(JSON.stringify(resp, null, 2));
136
+ }
137
+
138
+ if (toolName === "batch_resolve_my_bugs") {
139
+ const resp = await zentao.batchResolveMyBugs({
140
+ status: args.status || "active",
141
+ keyword: args.keyword || "",
142
+ limit: args.limit,
143
+ page: args.page,
144
+ maxItems: args.maxItems,
145
+ assignedTo: args.assignedTo || "",
146
+ resolution: args.resolution || "fixed",
147
+ comment: args.comment || "",
148
+ listPath: args.listPath || "/bugs",
149
+ resolvePath: args.resolvePath || "/bugs/{id}/resolve",
150
+ stopOnError: Boolean(args.stopOnError),
151
+ });
152
+ return toMcpTextResult(JSON.stringify(resp, null, 2));
153
+ }
154
+
155
+ if (toolName === "close_bug") {
156
+ const resp = await zentao.closeBug({
157
+ id: args.id,
158
+ comment: args.comment || "",
159
+ path: args.path || "/bugs/{id}/close",
160
+ });
161
+ return toMcpTextResult(JSON.stringify(resp, null, 2));
162
+ }
163
+
164
+ if (toolName === "verify_bug") {
165
+ const resp = await zentao.verifyBug({
166
+ id: args.id,
167
+ result: args.result || "pass",
168
+ comment: args.comment || "",
169
+ closePath: args.closePath || "/bugs/{id}/close",
170
+ activatePath: args.activatePath || "/bugs/{id}/activate",
171
+ });
172
+ return toMcpTextResult(JSON.stringify(resp, null, 2));
173
+ }
174
+
175
+ throw new Error(`Unknown tool: ${rawToolName}`);
81
176
  });
82
177
 
83
178
  const transport = new StdioServerTransport();
package/src/tools.js CHANGED
@@ -1,5 +1,8 @@
1
1
  export function toMcpTextResult(text) {
2
- return { content: [{ type: "text", text }] };
2
+ return {
3
+ content: [{ type: "text", text }],
4
+ structuredContent: { output: text },
5
+ };
3
6
  }
4
7
 
5
8
  export const TOOLS = [
@@ -36,6 +39,101 @@ export const TOOLS = [
36
39
  additionalProperties: false,
37
40
  },
38
41
  },
42
+ {
43
+ name: "get_my_bugs",
44
+ description: "List bugs assigned to me (supports status/keyword/limit/page filter).",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ status: { type: "string", description: "Optional status filter, e.g. active/resolved/closed" },
49
+ keyword: { type: "string", description: "Optional keyword in title/steps/severity/pri" },
50
+ limit: { type: "number", minimum: 1, maximum: 200, description: "Default 20, max 200" },
51
+ page: { type: "number", minimum: 1, description: "Default 1" },
52
+ path: { type: "string", description: "Optional bugs endpoint override, default /bugs" },
53
+ assignedTo: { type: "string", description: "Optional assignee override, default current account" },
54
+ },
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ {
59
+ name: "get_bug_detail",
60
+ description: "Get bug detail by ID.",
61
+ inputSchema: {
62
+ type: "object",
63
+ properties: {
64
+ id: { type: "number", minimum: 1, description: "Bug ID" },
65
+ path: { type: "string", description: "Optional detail endpoint template, default /bugs/{id}" },
66
+ },
67
+ required: ["id"],
68
+ additionalProperties: false,
69
+ },
70
+ },
71
+ {
72
+ name: "resolve_bug",
73
+ description: "Resolve one bug by ID (default resolution=fixed).",
74
+ inputSchema: {
75
+ type: "object",
76
+ properties: {
77
+ id: { type: "number", minimum: 1, description: "Bug ID" },
78
+ resolution: { type: "string", description: "Default fixed" },
79
+ comment: { type: "string", description: "Optional resolve comment" },
80
+ path: { type: "string", description: "Optional resolve endpoint template, default /bugs/{id}/resolve" },
81
+ },
82
+ required: ["id"],
83
+ additionalProperties: false,
84
+ },
85
+ },
86
+ {
87
+ name: "batch_resolve_my_bugs",
88
+ description: "Batch resolve my bugs (default status=active, resolution=fixed).",
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ status: { type: "string", description: "Default active" },
93
+ keyword: { type: "string", description: "Optional keyword filter before resolve" },
94
+ limit: { type: "number", minimum: 1, maximum: 200, description: "List page size, default 50" },
95
+ page: { type: "number", minimum: 1, description: "Default 1" },
96
+ maxItems: { type: "number", minimum: 1, maximum: 500, description: "Max resolve count, default 50" },
97
+ assignedTo: { type: "string", description: "Optional assignee override" },
98
+ resolution: { type: "string", description: "Default fixed" },
99
+ comment: { type: "string", description: "Optional resolve comment" },
100
+ listPath: { type: "string", description: "Optional list endpoint, default /bugs" },
101
+ resolvePath: { type: "string", description: "Optional resolve path template, default /bugs/{id}/resolve" },
102
+ stopOnError: { type: "boolean", description: "Default false; stop on first resolve failure" },
103
+ },
104
+ additionalProperties: false,
105
+ },
106
+ },
107
+ {
108
+ name: "close_bug",
109
+ description: "Close one bug by ID.",
110
+ inputSchema: {
111
+ type: "object",
112
+ properties: {
113
+ id: { type: "number", minimum: 1, description: "Bug ID" },
114
+ comment: { type: "string", description: "Optional close comment" },
115
+ path: { type: "string", description: "Optional close endpoint template, default /bugs/{id}/close" },
116
+ },
117
+ required: ["id"],
118
+ additionalProperties: false,
119
+ },
120
+ },
121
+ {
122
+ name: "verify_bug",
123
+ description: "Verify bug result: pass -> close, fail -> activate.",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ id: { type: "number", minimum: 1, description: "Bug ID" },
128
+ result: { type: "string", description: "pass or fail, default pass" },
129
+ comment: { type: "string", description: "Optional verification comment" },
130
+ closePath: { type: "string", description: "Optional close endpoint template, default /bugs/{id}/close" },
131
+ activatePath: { type: "string", description: "Optional activate endpoint template, default /bugs/{id}/activate" },
132
+ },
133
+ required: ["id"],
134
+ additionalProperties: false,
135
+ },
136
+ },
39
137
  ];
40
138
 
41
139
  export function assertToolArgs(name, args) {
@@ -44,4 +142,73 @@ export function assertToolArgs(name, args) {
44
142
  if (name === "call" && typeof args.path !== "string") {
45
143
  throw new Error("call.path must be a string");
46
144
  }
145
+ if (name === "get_my_bugs") {
146
+ if (args.limit !== undefined && (!Number.isFinite(args.limit) || args.limit < 1 || args.limit > 200)) {
147
+ throw new Error("get_my_bugs.limit must be a number between 1 and 200");
148
+ }
149
+ if (args.page !== undefined && (!Number.isFinite(args.page) || args.page < 1)) {
150
+ throw new Error("get_my_bugs.page must be a number >= 1");
151
+ }
152
+ if (args.path !== undefined && typeof args.path !== "string") {
153
+ throw new Error("get_my_bugs.path must be a string");
154
+ }
155
+ }
156
+ if (name === "get_bug_detail") {
157
+ if (!Number.isFinite(args.id) || Number(args.id) < 1) {
158
+ throw new Error("get_bug_detail.id must be a number >= 1");
159
+ }
160
+ if (args.path !== undefined && typeof args.path !== "string") {
161
+ throw new Error("get_bug_detail.path must be a string");
162
+ }
163
+ }
164
+ if (name === "resolve_bug") {
165
+ if (!Number.isFinite(args.id) || Number(args.id) < 1) {
166
+ throw new Error("resolve_bug.id must be a number >= 1");
167
+ }
168
+ if (args.path !== undefined && typeof args.path !== "string") {
169
+ throw new Error("resolve_bug.path must be a string");
170
+ }
171
+ }
172
+ if (name === "batch_resolve_my_bugs") {
173
+ if (args.limit !== undefined && (!Number.isFinite(args.limit) || args.limit < 1 || args.limit > 200)) {
174
+ throw new Error("batch_resolve_my_bugs.limit must be a number between 1 and 200");
175
+ }
176
+ if (args.page !== undefined && (!Number.isFinite(args.page) || args.page < 1)) {
177
+ throw new Error("batch_resolve_my_bugs.page must be a number >= 1");
178
+ }
179
+ if (args.maxItems !== undefined && (!Number.isFinite(args.maxItems) || args.maxItems < 1 || args.maxItems > 500)) {
180
+ throw new Error("batch_resolve_my_bugs.maxItems must be a number between 1 and 500");
181
+ }
182
+ if (args.listPath !== undefined && typeof args.listPath !== "string") {
183
+ throw new Error("batch_resolve_my_bugs.listPath must be a string");
184
+ }
185
+ if (args.resolvePath !== undefined && typeof args.resolvePath !== "string") {
186
+ throw new Error("batch_resolve_my_bugs.resolvePath must be a string");
187
+ }
188
+ }
189
+ if (name === "close_bug") {
190
+ if (!Number.isFinite(args.id) || Number(args.id) < 1) {
191
+ throw new Error("close_bug.id must be a number >= 1");
192
+ }
193
+ if (args.path !== undefined && typeof args.path !== "string") {
194
+ throw new Error("close_bug.path must be a string");
195
+ }
196
+ }
197
+ if (name === "verify_bug") {
198
+ if (!Number.isFinite(args.id) || Number(args.id) < 1) {
199
+ throw new Error("verify_bug.id must be a number >= 1");
200
+ }
201
+ if (args.result !== undefined) {
202
+ const result = String(args.result).toLowerCase();
203
+ if (result !== "pass" && result !== "fail") {
204
+ throw new Error("verify_bug.result must be pass or fail");
205
+ }
206
+ }
207
+ if (args.closePath !== undefined && typeof args.closePath !== "string") {
208
+ throw new Error("verify_bug.closePath must be a string");
209
+ }
210
+ if (args.activatePath !== undefined && typeof args.activatePath !== "string") {
211
+ throw new Error("verify_bug.activatePath must be a string");
212
+ }
213
+ }
47
214
  }
package/src/zentao.js CHANGED
@@ -154,6 +154,326 @@ export function createZenTaoClient(config) {
154
154
  return { total: list.length, matched: filtered.length, projects: filtered };
155
155
  }
156
156
 
157
+ function parseBugsFromResponse(data) {
158
+ if (Array.isArray(data?.bugs)) return data.bugs;
159
+ if (Array.isArray(data?.data?.bugs)) return data.data.bugs;
160
+ if (Array.isArray(data?.data)) return data.data;
161
+ if (Array.isArray(data)) return data;
162
+ return [];
163
+ }
164
+
165
+ function parseBugDetailFromResponse(data) {
166
+ if (data?.bug && typeof data.bug === "object") return data.bug;
167
+ if (data?.data?.bug && typeof data.data.bug === "object") return data.data.bug;
168
+ if (data?.data && typeof data.data === "object" && !Array.isArray(data.data)) return data.data;
169
+ if (data && typeof data === "object" && !Array.isArray(data)) return data;
170
+ return null;
171
+ }
172
+
173
+ function normalizeString(value) {
174
+ return String(value || "").trim().toLowerCase();
175
+ }
176
+
177
+ function getBugAssignee(bug) {
178
+ return (
179
+ bug?.assignedTo ||
180
+ bug?.assignedto ||
181
+ bug?.assigned_to ||
182
+ bug?.assignedUser ||
183
+ bug?.owner ||
184
+ ""
185
+ );
186
+ }
187
+
188
+ function matchesBugFilters(bug, { status, keyword, assignee }) {
189
+ const normalizedStatus = normalizeString(status);
190
+ const normalizedKeyword = normalizeString(keyword);
191
+ const normalizedAssignee = normalizeString(assignee);
192
+
193
+ if (normalizedStatus) {
194
+ const bugStatus = normalizeString(bug?.status);
195
+ if (bugStatus !== normalizedStatus) return false;
196
+ }
197
+
198
+ if (normalizedAssignee) {
199
+ const bugAssignee = normalizeString(getBugAssignee(bug));
200
+ if (bugAssignee !== normalizedAssignee) return false;
201
+ }
202
+
203
+ if (normalizedKeyword) {
204
+ const searchableText = [
205
+ bug?.title,
206
+ bug?.severity,
207
+ bug?.pri,
208
+ bug?.steps,
209
+ bug?.status,
210
+ getBugAssignee(bug),
211
+ ]
212
+ .filter(Boolean)
213
+ .map((value) => String(value).toLowerCase())
214
+ .join(" ");
215
+ if (!searchableText.includes(normalizedKeyword)) return false;
216
+ }
217
+
218
+ return true;
219
+ }
220
+
221
+ function buildBugDetailPath({ id, path }) {
222
+ const normalizedId = Number(id);
223
+ const basePath = path || "/bugs/{id}";
224
+ if (basePath.includes("{id}")) {
225
+ return basePath.replaceAll("{id}", String(normalizedId));
226
+ }
227
+ const trimmed = basePath.replace(/\/+$/, "");
228
+ return `${trimmed}/${normalizedId}`;
229
+ }
230
+
231
+ function buildBugResolvePath({ id, path }) {
232
+ return buildBugTransitionPath({ id, path, action: "resolve" });
233
+ }
234
+
235
+ function buildBugTransitionPath({ id, path, action }) {
236
+ const normalizedId = Number(id);
237
+ const safeAction = String(action || "").trim();
238
+ if (!safeAction) throw new Error("buildBugTransitionPath requires action");
239
+ const basePath = path || `/bugs/{id}/${safeAction}`;
240
+ if (basePath.includes("{id}")) {
241
+ return basePath.replaceAll("{id}", String(normalizedId));
242
+ }
243
+ const trimmed = basePath.replace(/\/+$/, "");
244
+ return `${trimmed}/${normalizedId}/${safeAction}`;
245
+ }
246
+
247
+ function getBugId(bug) {
248
+ const id = Number(
249
+ bug?.id ??
250
+ bug?.bugId ??
251
+ bug?.bugID ??
252
+ bug?.bug_id ??
253
+ bug?.bug?.id
254
+ );
255
+ return Number.isFinite(id) && id > 0 ? id : null;
256
+ }
257
+
258
+ function extractImageUrlsFromBug(bug) {
259
+ const images = new Set();
260
+ const addMatches = (value) => {
261
+ if (!value) return;
262
+ const text = String(value);
263
+ const srcRegex = /<img[^>]+src=["']([^"']+)["']/gi;
264
+ for (const match of text.matchAll(srcRegex)) {
265
+ if (match[1]) images.add(match[1]);
266
+ }
267
+ const urlRegex = /https?:\/\/[^\s"'<>]+\.(png|jpe?g|gif|webp|bmp|svg)/gi;
268
+ for (const match of text.matchAll(urlRegex)) {
269
+ if (match[0]) images.add(match[0]);
270
+ }
271
+ };
272
+ addMatches(bug?.steps);
273
+ addMatches(bug?.stepsHtml);
274
+ addMatches(bug?.openedBuild);
275
+ return Array.from(images);
276
+ }
277
+
278
+ async function getMyBugs({ status, keyword, limit = 20, page = 1, path = "/bugs", assignedTo } = {}) {
279
+ const safeLimit = Math.max(1, Math.min(Number(limit) || 20, 200));
280
+ const safePage = Math.max(1, Number(page) || 1);
281
+ const assignee = normalizeString(assignedTo) || normalizeString(auth.account);
282
+
283
+ const query = {
284
+ limit: safeLimit,
285
+ page: safePage,
286
+ assignedTo: assignee || undefined,
287
+ status: status || undefined,
288
+ };
289
+ const resp = await call({ path, method: "GET", query });
290
+ const bugs = parseBugsFromResponse(resp.data);
291
+ const filtered = bugs.filter((bug) => matchesBugFilters(bug, { status, keyword, assignee }));
292
+
293
+ return {
294
+ total: bugs.length,
295
+ matched: filtered.length,
296
+ page: safePage,
297
+ limit: safeLimit,
298
+ assignedTo: assignee,
299
+ bugs: filtered,
300
+ raw: { status: resp.status },
301
+ };
302
+ }
303
+
304
+ async function getBugDetail({ id, path = "/bugs/{id}" } = {}) {
305
+ const bugId = Number(id);
306
+ if (!Number.isFinite(bugId) || bugId < 1) {
307
+ throw new Error("getBugDetail requires a valid bug id");
308
+ }
309
+
310
+ const detailPath = buildBugDetailPath({ id: bugId, path });
311
+ const resp = await call({ path: detailPath, method: "GET" });
312
+ const bug = parseBugDetailFromResponse(resp.data);
313
+ if (!bug) {
314
+ return {
315
+ id: bugId,
316
+ found: false,
317
+ images: [],
318
+ raw: { status: resp.status, data: resp.data },
319
+ };
320
+ }
321
+
322
+ return {
323
+ id: bugId,
324
+ found: true,
325
+ bug,
326
+ images: extractImageUrlsFromBug(bug),
327
+ raw: { status: resp.status },
328
+ };
329
+ }
330
+
331
+ async function resolveBug({ id, resolution = "fixed", comment = "", path = "/bugs/{id}/resolve" } = {}) {
332
+ const bugId = Number(id);
333
+ if (!Number.isFinite(bugId) || bugId < 1) {
334
+ throw new Error("resolveBug requires a valid bug id");
335
+ }
336
+
337
+ const resolvePath = buildBugResolvePath({ id: bugId, path });
338
+ const body = { resolution: String(resolution || "fixed") };
339
+ if (comment) body.comment = String(comment);
340
+
341
+ const resp = await call({ path: resolvePath, method: "POST", body });
342
+ return {
343
+ id: bugId,
344
+ resolved: true,
345
+ resolution: body.resolution,
346
+ raw: { status: resp.status, data: resp.data },
347
+ };
348
+ }
349
+
350
+ async function closeBug({ id, comment = "", path = "/bugs/{id}/close" } = {}) {
351
+ const bugId = Number(id);
352
+ if (!Number.isFinite(bugId) || bugId < 1) {
353
+ throw new Error("closeBug requires a valid bug id");
354
+ }
355
+
356
+ const closePath = buildBugTransitionPath({ id: bugId, path, action: "close" });
357
+ const body = {};
358
+ if (comment) body.comment = String(comment);
359
+
360
+ const resp = await call({ path: closePath, method: "POST", body });
361
+ return {
362
+ id: bugId,
363
+ closed: true,
364
+ raw: { status: resp.status, data: resp.data },
365
+ };
366
+ }
367
+
368
+ async function activateBug({ id, comment = "", path = "/bugs/{id}/activate" } = {}) {
369
+ const bugId = Number(id);
370
+ if (!Number.isFinite(bugId) || bugId < 1) {
371
+ throw new Error("activateBug requires a valid bug id");
372
+ }
373
+
374
+ const activatePath = buildBugTransitionPath({ id: bugId, path, action: "activate" });
375
+ const body = {};
376
+ if (comment) body.comment = String(comment);
377
+
378
+ const resp = await call({ path: activatePath, method: "POST", body });
379
+ return {
380
+ id: bugId,
381
+ activated: true,
382
+ raw: { status: resp.status, data: resp.data },
383
+ };
384
+ }
385
+
386
+ async function verifyBug({
387
+ id,
388
+ result = "pass",
389
+ comment = "",
390
+ closePath = "/bugs/{id}/close",
391
+ activatePath = "/bugs/{id}/activate",
392
+ } = {}) {
393
+ const normalizedResult = normalizeString(result || "pass");
394
+ if (normalizedResult !== "pass" && normalizedResult !== "fail") {
395
+ throw new Error("verifyBug.result must be pass or fail");
396
+ }
397
+
398
+ if (normalizedResult === "pass") {
399
+ const closeResult = await closeBug({ id, comment, path: closePath });
400
+ return {
401
+ id: Number(id),
402
+ verified: true,
403
+ result: "pass",
404
+ action: "close",
405
+ raw: closeResult.raw,
406
+ };
407
+ }
408
+
409
+ const activateResult = await activateBug({ id, comment, path: activatePath });
410
+ return {
411
+ id: Number(id),
412
+ verified: true,
413
+ result: "fail",
414
+ action: "activate",
415
+ raw: activateResult.raw,
416
+ };
417
+ }
418
+
419
+ async function batchResolveMyBugs({
420
+ status = "active",
421
+ keyword = "",
422
+ limit = 50,
423
+ page = 1,
424
+ maxItems = 50,
425
+ assignedTo = "",
426
+ resolution = "fixed",
427
+ comment = "",
428
+ listPath = "/bugs",
429
+ resolvePath = "/bugs/{id}/resolve",
430
+ stopOnError = false,
431
+ } = {}) {
432
+ const safeMaxItems = Math.max(1, Math.min(Number(maxItems) || 50, 500));
433
+ const listResult = await getMyBugs({
434
+ status,
435
+ keyword,
436
+ limit,
437
+ page,
438
+ path: listPath,
439
+ assignedTo,
440
+ });
441
+
442
+ const candidates = (listResult.bugs || []).slice(0, safeMaxItems);
443
+ const success = [];
444
+ const failed = [];
445
+
446
+ for (const bug of candidates) {
447
+ const bugId = getBugId(bug);
448
+ if (!bugId) {
449
+ failed.push({ id: null, error: "Missing bug id in list item" });
450
+ if (stopOnError) break;
451
+ continue;
452
+ }
453
+ try {
454
+ const result = await resolveBug({
455
+ id: bugId,
456
+ resolution,
457
+ comment,
458
+ path: resolvePath,
459
+ });
460
+ success.push({ id: bugId, status: result.raw.status });
461
+ } catch (err) {
462
+ failed.push({ id: bugId, error: String(err?.message || err) });
463
+ if (stopOnError) break;
464
+ }
465
+ }
466
+
467
+ return {
468
+ requested: listResult.matched,
469
+ attempted: candidates.length,
470
+ resolved: success.length,
471
+ failed: failed.length,
472
+ success,
473
+ errors: failed,
474
+ };
475
+ }
476
+
157
477
  // 轻量重试:禅道偶发 502/网关问题时可用;默认不用,保留扩展点
158
478
  async function callWithRetry(args, { retries = 0, backoffMs = 200 } = {}) {
159
479
  let lastErr;
@@ -169,5 +489,16 @@ export function createZenTaoClient(config) {
169
489
  throw lastErr;
170
490
  }
171
491
 
172
- return { getToken, call, callWithRetry, listMyProjects };
492
+ return {
493
+ getToken,
494
+ call,
495
+ callWithRetry,
496
+ listMyProjects,
497
+ getMyBugs,
498
+ getBugDetail,
499
+ resolveBug,
500
+ closeBug,
501
+ verifyBug,
502
+ batchResolveMyBugs,
503
+ };
173
504
  }