@aipper/zentao-mcp-server 0.1.7 → 0.1.9

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
@@ -11,6 +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
+
17
+ # 可选:我的 bug 列表路径(例如 /my/bug 或 /my/bugs)
18
+ # ZENTAO_MY_BUGS_PATH=/my/bug
19
+
20
+ # 可选:bug 列表回退路径(逗号分隔)
21
+ # ZENTAO_BUGS_FALLBACK_PATHS=/my/bug,/my/bugs
22
+
23
+ # 可选:项目集 bug 路径模板(逗号分隔,支持 {projectSetId} 占位)
24
+ # ZENTAO_PROJECT_SET_BUGS_PATHS=/projectsets/{projectSetId}/bugs,/programs/{projectSetId}/bugs
25
+
14
26
  # 获取 Token 的路径(相对 ZENTAO_BASE_URL;通常为 /api.php/v1/tokens)
15
27
  ZENTAO_TOKEN_PATH=/api.php/v1/tokens
16
28
 
package/README.md CHANGED
@@ -17,6 +17,10 @@
17
17
  - `ZENTAO_ACCOUNT`
18
18
  - `ZENTAO_PASSWORD`
19
19
  - (可选)`ZENTAO_PRODUCT_ID`:你的禅道实例若报 `Need product id` 时设置
20
+ - (可选)`ZENTAO_PROJECT_SET_ID`:你的 bug 若在项目集视角,设置项目集 ID
21
+ - (可选)`ZENTAO_MY_BUGS_PATH`:我的 bug 专用接口路径(如 `/my/bug`)
22
+ - (可选)`ZENTAO_BUGS_FALLBACK_PATHS`:bug 列表回退路径(逗号分隔)
23
+ - (可选)`ZENTAO_PROJECT_SET_BUGS_PATHS`:项目集 bug 路径模板(支持 `{projectSetId}`)
20
24
 
21
25
  > 注意:不同禅道版本/部署方式的 token 端点与返回结构可能不同;可通过 `ZENTAO_TOKEN_PATH`/`ZENTAO_API_PREFIX` 调整。
22
26
  >
@@ -88,6 +92,9 @@ npm run smoke
88
92
  `-32000` 通常是客户端侧“通用 MCP 调用失败”映射码,优先检查:
89
93
  - `env` 是否完整传入(尤其是 `ZENTAO_BASE_URL`/`ZENTAO_ACCOUNT`/`ZENTAO_PASSWORD`)。
90
94
  - 若报 `Need product id`,请设置 `ZENTAO_PRODUCT_ID`,或在 `get_my_bugs` 传 `productId`。
95
+ - 若你的 bug 在“项目集/我的视角”而非产品,建议设置 `ZENTAO_PROJECT_SET_ID`,并配置 `ZENTAO_MY_BUGS_PATH=/my/bug`。
96
+ - `get_my_bugs` 会按候选路径回退(包含项目集路径);即使首个路径返回空列表也会继续尝试。
97
+ - 排查时看工具返回里的 `raw.triedPaths`,可确认每条路径的返回码与命中数量。
91
98
  - `ZENTAO_API_PREFIX`/`ZENTAO_TOKEN_PATH` 是否和你的禅道实例一致。
92
99
  - MCP 客户端是否真的在执行 `npx -y @aipper/zentao-mcp-server`(而不是旧的本地命令)。
93
100
  - 客户端日志中是否有启动报错(如找不到命令、401、超时)。
@@ -96,19 +103,25 @@ npm run smoke
96
103
  - `get_token`:获取/刷新 token(默认不回显完整 token)
97
104
  - `call`:调用任意相对 API 路径(自动带 Token 头)
98
105
  - `list_my_projects`:示例:列出“我参与的项目”(字段匹配基于常见返回结构,可能需按你的实例微调)
99
- - `get_my_bugs`:获取“指派给我”的 bug(支持 `status`/`keyword`/`limit`/`page`/`productId`,默认路径 `/bugs`)
100
- - `get_bug_detail`:按 `id` 获取 bug 详情(默认路径模板 `/bugs/{id}`,返回详情与图片链接)
101
- - `resolve_bug`:按 `id` 处理单个 bug 状态(默认 `resolution=fixed`)
102
- - `batch_resolve_my_bugs`:批量处理“我的 bug”(默认筛选 `status=active`,支持 `productId`)
106
+ - `get_my_bugs`:获取“指派给我”的 bug(支持 `status`/`keyword`/`limit`/`page`/`productId`/`projectSetId`,默认路径 `/bugs`)
107
+ - `get_bug_detail`:按 `id` 获取 bug 详情(默认路径模板 `/bugs/{id}`,返回详情与图片链接;会提取富文本 `<img>`、Markdown 图片、附件图片并归一化为可访问 URL)
108
+ - `resolve_bug`:按 `id` 处理单个 bug 状态(默认 `resolution=fixed`,支持 `solution` 解决说明)
109
+ - `batch_resolve_my_bugs`:批量处理“我的 bug”(默认筛选 `status=active`,支持 `productId`/`projectSetId`)
103
110
  - `close_bug`:按 `id` 关闭 bug
104
111
  - `verify_bug`:验证结果处理(`pass`=关闭,`fail`=激活)
112
+ - `comment_bug`:按 `id` 添加备注(默认路径 `/bugs/{id}/comment`)
105
113
 
106
114
  示例参数:
107
115
  - `resolve_bug`:`{"id":123,"resolution":"fixed","comment":"已修复并自测"}`
116
+ - `resolve_bug`(建议):`{"id":123,"resolution":"fixed","solution":"修复空指针并补充参数校验"}`
108
117
  - `batch_resolve_my_bugs`:`{"status":"active","maxItems":20,"comment":"批量修复"}`
118
+ - `batch_resolve_my_bugs`(建议):`{"status":"active","maxItems":20,"solution":"统一修复分页参数为空导致的报错"}`
109
119
  - `get_my_bugs`(按产品):`{"status":"active","productId":1,"limit":50}`
120
+ - `get_my_bugs`(项目集):`{"status":"active","projectSetId":1001,"limit":50}`
121
+ - `get_my_bugs`(我的):`{"status":"active","path":"/my/bug","limit":50}`
110
122
  - `close_bug`:`{"id":123,"comment":"验证通过,关闭"}`
111
123
  - `verify_bug`:`{"id":123,"result":"pass","comment":"验证通过"}`
124
+ - `comment_bug`:`{"id":123,"comment":"已复现,正在定位根因"}`
112
125
 
113
126
  ## 安全建议
114
127
  - 使用最小权限账号(仅需要的项目权限),避免使用管理员账号。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aipper/zentao-mcp-server",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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).",
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ const KNOWN_TOOL_NAMES = new Set([
18
18
  "batch_resolve_my_bugs",
19
19
  "close_bug",
20
20
  "verify_bug",
21
+ "comment_bug",
21
22
  ]);
22
23
 
23
24
  function normalizeToolName(rawName) {
@@ -49,6 +50,16 @@ function getConfigFromEnv() {
49
50
  const timeoutMs = Number(process.env.ZENTAO_HTTP_TIMEOUT_MS || "30000");
50
51
  const exposeToken = String(process.env.ZENTAO_EXPOSE_TOKEN || "false").toLowerCase() === "true";
51
52
  const defaultProductId = Number(process.env.ZENTAO_PRODUCT_ID || "0") || null;
53
+ const defaultProjectSetId = Number(process.env.ZENTAO_PROJECT_SET_ID || "0") || null;
54
+ const myBugsPath = String(process.env.ZENTAO_MY_BUGS_PATH || "").trim();
55
+ const bugsFallbackPaths = String(process.env.ZENTAO_BUGS_FALLBACK_PATHS || "")
56
+ .split(",")
57
+ .map((value) => value.trim())
58
+ .filter(Boolean);
59
+ const projectSetBugsPaths = String(process.env.ZENTAO_PROJECT_SET_BUGS_PATHS || "")
60
+ .split(",")
61
+ .map((value) => value.trim())
62
+ .filter(Boolean);
52
63
 
53
64
  const account = requireEnv("ZENTAO_ACCOUNT");
54
65
  const password = requireEnv("ZENTAO_PASSWORD");
@@ -61,6 +72,10 @@ function getConfigFromEnv() {
61
72
  timeoutMs,
62
73
  exposeToken,
63
74
  defaultProductId,
75
+ defaultProjectSetId,
76
+ myBugsPath,
77
+ bugsFallbackPaths,
78
+ projectSetBugsPaths,
64
79
  auth: { account, password },
65
80
  };
66
81
  }
@@ -116,6 +131,7 @@ async function main() {
116
131
  limit: args.limit,
117
132
  page: args.page,
118
133
  productId: args.productId,
134
+ projectSetId: args.projectSetId,
119
135
  path: args.path || "/bugs",
120
136
  assignedTo: args.assignedTo || "",
121
137
  });
@@ -134,6 +150,7 @@ async function main() {
134
150
  const resp = await zentao.resolveBug({
135
151
  id: args.id,
136
152
  resolution: args.resolution || "fixed",
153
+ solution: args.solution || "",
137
154
  comment: args.comment || "",
138
155
  path: args.path || "/bugs/{id}/resolve",
139
156
  });
@@ -147,9 +164,11 @@ async function main() {
147
164
  limit: args.limit,
148
165
  page: args.page,
149
166
  productId: args.productId,
167
+ projectSetId: args.projectSetId,
150
168
  maxItems: args.maxItems,
151
169
  assignedTo: args.assignedTo || "",
152
170
  resolution: args.resolution || "fixed",
171
+ solution: args.solution || "",
153
172
  comment: args.comment || "",
154
173
  listPath: args.listPath || "/bugs",
155
174
  resolvePath: args.resolvePath || "/bugs/{id}/resolve",
@@ -178,6 +197,15 @@ async function main() {
178
197
  return toMcpTextResult(JSON.stringify(resp, null, 2));
179
198
  }
180
199
 
200
+ if (toolName === "comment_bug") {
201
+ const resp = await zentao.commentBug({
202
+ id: args.id,
203
+ comment: args.comment || "",
204
+ path: args.path || "/bugs/{id}/comment",
205
+ });
206
+ return toMcpTextResult(JSON.stringify(resp, null, 2));
207
+ }
208
+
181
209
  throw new Error(`Unknown tool: ${rawToolName}`);
182
210
  } catch (err) {
183
211
  const errorPayload = {
@@ -187,6 +215,7 @@ async function main() {
187
215
  status: err?.status ?? null,
188
216
  data: err?.data ?? null,
189
217
  hint: "If you see 'Need product id', set env ZENTAO_PRODUCT_ID or pass productId in get_my_bugs.",
218
+ hint2: "For project-set instances, set ZENTAO_PROJECT_SET_ID or ZENTAO_MY_BUGS_PATH.",
190
219
  };
191
220
  return toMcpTextResult(JSON.stringify(errorPayload, null, 2), { isError: true });
192
221
  }
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
  },
@@ -80,6 +81,7 @@ export const TOOLS = [
80
81
  properties: {
81
82
  id: { type: "number", minimum: 1, description: "Bug ID" },
82
83
  resolution: { type: "string", description: "Default fixed" },
84
+ solution: { type: "string", description: "Resolution description (preferred)" },
83
85
  comment: { type: "string", description: "Optional resolve comment" },
84
86
  path: { type: "string", description: "Optional resolve endpoint template, default /bugs/{id}/resolve" },
85
87
  },
@@ -98,9 +100,11 @@ export const TOOLS = [
98
100
  limit: { type: "number", minimum: 1, maximum: 200, description: "List page size, default 50" },
99
101
  page: { type: "number", minimum: 1, description: "Default 1" },
100
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)" },
101
104
  maxItems: { type: "number", minimum: 1, maximum: 500, description: "Max resolve count, default 50" },
102
105
  assignedTo: { type: "string", description: "Optional assignee override" },
103
106
  resolution: { type: "string", description: "Default fixed" },
107
+ solution: { type: "string", description: "Resolution description (preferred)" },
104
108
  comment: { type: "string", description: "Optional resolve comment" },
105
109
  listPath: { type: "string", description: "Optional list endpoint, default /bugs" },
106
110
  resolvePath: { type: "string", description: "Optional resolve path template, default /bugs/{id}/resolve" },
@@ -139,6 +143,20 @@ export const TOOLS = [
139
143
  additionalProperties: false,
140
144
  },
141
145
  },
146
+ {
147
+ name: "comment_bug",
148
+ description: "Add comment to one bug by ID.",
149
+ inputSchema: {
150
+ type: "object",
151
+ properties: {
152
+ id: { type: "number", minimum: 1, description: "Bug ID" },
153
+ comment: { type: "string", description: "Comment content" },
154
+ path: { type: "string", description: "Optional comment endpoint template, default /bugs/{id}/comment" },
155
+ },
156
+ required: ["id", "comment"],
157
+ additionalProperties: false,
158
+ },
159
+ },
142
160
  ];
143
161
 
144
162
  export function assertToolArgs(name, args) {
@@ -160,6 +178,9 @@ export function assertToolArgs(name, args) {
160
178
  if (args.productId !== undefined && (!Number.isFinite(args.productId) || args.productId < 1)) {
161
179
  throw new Error("get_my_bugs.productId must be a number >= 1");
162
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
+ }
163
184
  }
164
185
  if (name === "get_bug_detail") {
165
186
  if (!Number.isFinite(args.id) || Number(args.id) < 1) {
@@ -176,6 +197,9 @@ export function assertToolArgs(name, args) {
176
197
  if (args.path !== undefined && typeof args.path !== "string") {
177
198
  throw new Error("resolve_bug.path must be a string");
178
199
  }
200
+ if (args.solution !== undefined && typeof args.solution !== "string") {
201
+ throw new Error("resolve_bug.solution must be a string");
202
+ }
179
203
  }
180
204
  if (name === "batch_resolve_my_bugs") {
181
205
  if (args.limit !== undefined && (!Number.isFinite(args.limit) || args.limit < 1 || args.limit > 200)) {
@@ -190,12 +214,18 @@ export function assertToolArgs(name, args) {
190
214
  if (args.productId !== undefined && (!Number.isFinite(args.productId) || args.productId < 1)) {
191
215
  throw new Error("batch_resolve_my_bugs.productId must be a number >= 1");
192
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
+ }
193
220
  if (args.listPath !== undefined && typeof args.listPath !== "string") {
194
221
  throw new Error("batch_resolve_my_bugs.listPath must be a string");
195
222
  }
196
223
  if (args.resolvePath !== undefined && typeof args.resolvePath !== "string") {
197
224
  throw new Error("batch_resolve_my_bugs.resolvePath must be a string");
198
225
  }
226
+ if (args.solution !== undefined && typeof args.solution !== "string") {
227
+ throw new Error("batch_resolve_my_bugs.solution must be a string");
228
+ }
199
229
  }
200
230
  if (name === "close_bug") {
201
231
  if (!Number.isFinite(args.id) || Number(args.id) < 1) {
@@ -222,4 +252,15 @@ export function assertToolArgs(name, args) {
222
252
  throw new Error("verify_bug.activatePath must be a string");
223
253
  }
224
254
  }
255
+ if (name === "comment_bug") {
256
+ if (!Number.isFinite(args.id) || Number(args.id) < 1) {
257
+ throw new Error("comment_bug.id must be a number >= 1");
258
+ }
259
+ if (typeof args.comment !== "string" || !args.comment.trim()) {
260
+ throw new Error("comment_bug.comment must be a non-empty string");
261
+ }
262
+ if (args.path !== undefined && typeof args.path !== "string") {
263
+ throw new Error("comment_bug.path must be a string");
264
+ }
265
+ }
225
266
  }
package/src/zentao.js CHANGED
@@ -31,7 +31,19 @@ function createAbortSignal(timeoutMs) {
31
31
  }
32
32
 
33
33
  export function createZenTaoClient(config) {
34
- const { baseUrl, apiPrefix, tokenPath, tokenTtlMs, timeoutMs, defaultProductId, auth } = config;
34
+ const {
35
+ baseUrl,
36
+ apiPrefix,
37
+ tokenPath,
38
+ tokenTtlMs,
39
+ timeoutMs,
40
+ defaultProductId,
41
+ defaultProjectSetId,
42
+ myBugsPath,
43
+ bugsFallbackPaths,
44
+ projectSetBugsPaths,
45
+ auth,
46
+ } = config;
35
47
 
36
48
  let cachedToken = "";
37
49
  let cachedAt = 0;
@@ -193,11 +205,62 @@ export function createZenTaoClient(config) {
193
205
  return basePath;
194
206
  }
195
207
 
208
+ function buildMyBugsPath(pathTemplate) {
209
+ const template = String(pathTemplate || "").trim();
210
+ if (!template) return "";
211
+ return template.replaceAll("{account}", encodeURIComponent(auth.account || ""));
212
+ }
213
+
214
+ function buildProjectSetPath(pathTemplate, projectSetId) {
215
+ const template = String(pathTemplate || "").trim();
216
+ if (!template) return "";
217
+ const pid = normalizePositiveInt(projectSetId);
218
+ if (!pid) return "";
219
+ return template.replaceAll("{projectSetId}", String(pid));
220
+ }
221
+
222
+ function isMyBugsPath(path) {
223
+ const normalized = String(path || "").toLowerCase();
224
+ return normalized.includes("/my/bug");
225
+ }
226
+
227
+ function isProjectSetPath(path) {
228
+ const normalized = String(path || "").toLowerCase();
229
+ return (
230
+ normalized.includes("/projectset") ||
231
+ normalized.includes("/project-set") ||
232
+ normalized.includes("/projectsets") ||
233
+ normalized.includes("/programs/")
234
+ );
235
+ }
236
+
196
237
  function isNeedProductIdError(err) {
197
238
  const merged = `${String(err?.message || "")} ${JSON.stringify(err?.data || "")}`.toLowerCase();
198
239
  return merged.includes("need product id");
199
240
  }
200
241
 
242
+ function buildBugsQueryForPath({ path, limit, page, assignedTo, status, productId }) {
243
+ if (isMyBugsPath(path) || isProjectSetPath(path)) {
244
+ // "我的bug"和"项目集bug"类端点在部分实例不接受 assignedTo/status/product 参数,使用最小分页参数后本地过滤。
245
+ return { limit, page };
246
+ }
247
+ return {
248
+ limit,
249
+ page,
250
+ assignedTo: assignedTo || undefined,
251
+ status: status || undefined,
252
+ product: productId || undefined,
253
+ };
254
+ }
255
+
256
+ function buildResolutionComment({ solution, comment, resolution }) {
257
+ const normalizedSolution = String(solution || "").trim();
258
+ if (normalizedSolution) return `解决说明:${normalizedSolution}`;
259
+ const normalizedComment = String(comment || "").trim();
260
+ if (normalizedComment) return normalizedComment;
261
+ return `已处理,resolution=${String(resolution || "fixed")}`;
262
+ }
263
+
201
264
  function getBugAssignee(bug) {
202
265
  return (
203
266
  bug?.assignedTo ||
@@ -256,6 +319,16 @@ export function createZenTaoClient(config) {
256
319
  return buildBugTransitionPath({ id, path, action: "resolve" });
257
320
  }
258
321
 
322
+ function buildBugCommentPath({ id, path }) {
323
+ const normalizedId = Number(id);
324
+ const basePath = path || "/bugs/{id}/comment";
325
+ if (basePath.includes("{id}")) {
326
+ return basePath.replaceAll("{id}", String(normalizedId));
327
+ }
328
+ const trimmed = basePath.replace(/\/+$/, "");
329
+ return `${trimmed}/${normalizedId}/comment`;
330
+ }
331
+
259
332
  function buildBugTransitionPath({ id, path, action }) {
260
333
  const normalizedId = Number(id);
261
334
  const safeAction = String(action || "").trim();
@@ -279,23 +352,105 @@ export function createZenTaoClient(config) {
279
352
  return Number.isFinite(id) && id > 0 ? id : null;
280
353
  }
281
354
 
355
+ function normalizeResourceUrl(value) {
356
+ const raw = String(value || "").trim();
357
+ if (!raw) return "";
358
+ if (raw.startsWith("data:")) return "";
359
+ const cleaned = raw.replaceAll("&amp;", "&");
360
+ if (/^https?:\/\//i.test(cleaned)) return cleaned;
361
+ if (cleaned.startsWith("//")) {
362
+ const protocol = new URL(baseUrl).protocol || "https:";
363
+ return `${protocol}${cleaned}`;
364
+ }
365
+ if (cleaned.startsWith("/")) {
366
+ return new URL(cleaned, `${baseUrl}/`).toString();
367
+ }
368
+ const normalized = cleaned.replace(/^\.?\//, "");
369
+ if (!normalized) return "";
370
+ return new URL(`/${normalized}`, `${baseUrl}/`).toString();
371
+ }
372
+
373
+ function isImageLikeText(value) {
374
+ const text = String(value || "").toLowerCase();
375
+ if (!text) return false;
376
+ if (text.includes("image/")) return true;
377
+ return /\.(png|jpe?g|gif|webp|bmp|svg|ico|avif)(?:$|[?#])/i.test(text);
378
+ }
379
+
380
+ function fileRecordLooksImage(record) {
381
+ if (!record || typeof record !== "object") return false;
382
+ if (record.isImage === true || record.isImage === 1 || record.image === true) return true;
383
+ const probes = [
384
+ record.mime,
385
+ record.contentType,
386
+ record.extension,
387
+ record.ext,
388
+ record.title,
389
+ record.name,
390
+ record.pathname,
391
+ record.path,
392
+ record.url,
393
+ record.webPath,
394
+ record.downloadUrl,
395
+ record.downloadurl,
396
+ ];
397
+ return probes.some((value) => isImageLikeText(value));
398
+ }
399
+
282
400
  function extractImageUrlsFromBug(bug) {
283
401
  const images = new Set();
402
+ const addImageUrl = (value) => {
403
+ const url = normalizeResourceUrl(value);
404
+ if (url) images.add(url);
405
+ };
284
406
  const addMatches = (value) => {
285
407
  if (!value) return;
286
408
  const text = String(value);
287
409
  const srcRegex = /<img[^>]+src=["']([^"']+)["']/gi;
288
410
  for (const match of text.matchAll(srcRegex)) {
289
- if (match[1]) images.add(match[1]);
411
+ if (match[1]) addImageUrl(match[1]);
290
412
  }
291
- const urlRegex = /https?:\/\/[^\s"'<>]+\.(png|jpe?g|gif|webp|bmp|svg)/gi;
413
+ const markdownImageRegex = /!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/gi;
414
+ for (const match of text.matchAll(markdownImageRegex)) {
415
+ if (match[1]) addImageUrl(match[1]);
416
+ }
417
+ const urlRegex = /https?:\/\/[^\s"'<>]+/gi;
292
418
  for (const match of text.matchAll(urlRegex)) {
293
- if (match[0]) images.add(match[0]);
419
+ if (isImageLikeText(match[0])) addImageUrl(match[0]);
420
+ }
421
+ };
422
+
423
+ const addImagesFromFiles = (files) => {
424
+ if (!files) return;
425
+ const list = Array.isArray(files) ? files : Object.values(files);
426
+ for (const item of list) {
427
+ if (!item || typeof item !== "object") continue;
428
+ if (!fileRecordLooksImage(item)) continue;
429
+ const candidates = [
430
+ item.webPath,
431
+ item.url,
432
+ item.downloadUrl,
433
+ item.downloadurl,
434
+ item.path,
435
+ item.pathname,
436
+ item.viewUrl,
437
+ item.href,
438
+ ];
439
+ for (const candidate of candidates) {
440
+ if (candidate) addImageUrl(candidate);
441
+ }
294
442
  }
295
443
  };
444
+
296
445
  addMatches(bug?.steps);
297
446
  addMatches(bug?.stepsHtml);
447
+ addMatches(bug?.reproStep);
448
+ addMatches(bug?.comment);
449
+ addMatches(bug?.resolution);
298
450
  addMatches(bug?.openedBuild);
451
+ addImagesFromFiles(bug?.files);
452
+ addImagesFromFiles(bug?.attachments);
453
+ addImagesFromFiles(bug?.openedFiles);
299
454
  return Array.from(images);
300
455
  }
301
456
 
@@ -305,6 +460,7 @@ export function createZenTaoClient(config) {
305
460
  limit = 20,
306
461
  page = 1,
307
462
  productId,
463
+ projectSetId,
308
464
  path = "/bugs",
309
465
  assignedTo,
310
466
  } = {}) {
@@ -312,43 +468,110 @@ export function createZenTaoClient(config) {
312
468
  const safePage = Math.max(1, Number(page) || 1);
313
469
  const assignee = normalizeString(assignedTo) || normalizeString(auth.account);
314
470
  const effectiveProductId = normalizePositiveInt(productId) || normalizePositiveInt(defaultProductId);
471
+ const effectiveProjectSetId = normalizePositiveInt(projectSetId) || normalizePositiveInt(defaultProjectSetId);
315
472
  const primaryPath = buildProductScopedBugsPath({ productId: effectiveProductId, path });
316
-
317
- const query = {
473
+ const preferProjectSetPath =
474
+ effectiveProjectSetId &&
475
+ (!path || String(path).trim() === "" || String(path).trim() === "/bugs");
476
+ const configuredMyBugsPath = buildMyBugsPath(myBugsPath);
477
+ const fallbackPathCandidates = (bugsFallbackPaths && bugsFallbackPaths.length > 0)
478
+ ? bugsFallbackPaths
479
+ : ["/my/bug", "/my/bugs"];
480
+ const fallbackPaths = fallbackPathCandidates.map((item) => buildMyBugsPath(item)).filter(Boolean);
481
+ const projectSetCandidates = (projectSetBugsPaths && projectSetBugsPaths.length > 0)
482
+ ? projectSetBugsPaths
483
+ : ["/projectsets/{projectSetId}/bugs", "/projectset/{projectSetId}/bugs", "/programs/{projectSetId}/bugs"];
484
+ const projectSetPaths = projectSetCandidates
485
+ .map((item) => buildProjectSetPath(item, effectiveProjectSetId))
486
+ .filter(Boolean);
487
+
488
+ const baseQuery = {
318
489
  limit: safeLimit,
319
490
  page: safePage,
320
- assignedTo: assignee || undefined,
321
- status: status || undefined,
322
- product: effectiveProductId || undefined,
491
+ assignedTo: assignee,
492
+ status,
493
+ productId: effectiveProductId,
323
494
  };
324
- let resp;
325
- try {
326
- resp = await call({ path: primaryPath, method: "GET", query });
327
- } catch (err) {
328
- const fallbackPath = effectiveProductId ? `/products/${effectiveProductId}/bugs` : null;
329
- if (
330
- fallbackPath &&
331
- primaryPath !== fallbackPath &&
332
- isNeedProductIdError(err)
333
- ) {
334
- resp = await call({ path: fallbackPath, method: "GET", query: { limit: safeLimit, page: safePage } });
335
- } else {
336
- throw err;
495
+ const candidatePaths = [];
496
+ if (preferProjectSetPath) {
497
+ for (const projectSetPath of projectSetPaths) {
498
+ if (!candidatePaths.includes(projectSetPath)) candidatePaths.push(projectSetPath);
337
499
  }
338
500
  }
501
+ candidatePaths.push(primaryPath);
502
+ if (effectiveProductId) {
503
+ const productPath = `/products/${effectiveProductId}/bugs`;
504
+ if (!candidatePaths.includes(productPath)) candidatePaths.push(productPath);
505
+ }
506
+ if (!preferProjectSetPath) {
507
+ for (const projectSetPath of projectSetPaths) {
508
+ if (!candidatePaths.includes(projectSetPath)) candidatePaths.push(projectSetPath);
509
+ }
510
+ }
511
+ if (configuredMyBugsPath && !candidatePaths.includes(configuredMyBugsPath)) {
512
+ candidatePaths.push(configuredMyBugsPath);
513
+ }
514
+ for (const fallback of fallbackPaths) {
515
+ if (!candidatePaths.includes(fallback)) candidatePaths.push(fallback);
516
+ }
339
517
 
340
- const bugs = parseBugsFromResponse(resp?.data);
341
- const filtered = bugs.filter((bug) => matchesBugFilters(bug, { status, keyword, assignee }));
518
+ let bestResult = null;
519
+ let lastErr = null;
520
+ const triedPaths = [];
521
+ for (const candidate of candidatePaths) {
522
+ try {
523
+ const query = buildBugsQueryForPath({ path: candidate, ...baseQuery });
524
+ const resp = await call({ path: candidate, method: "GET", query });
525
+ const bugs = parseBugsFromResponse(resp?.data);
526
+ const filtered = bugs.filter((bug) => matchesBugFilters(bug, { status, keyword, assignee }));
527
+
528
+ triedPaths.push({
529
+ path: candidate,
530
+ status: resp?.status ?? null,
531
+ total: bugs.length,
532
+ matched: filtered.length,
533
+ });
534
+
535
+ const currentResult = {
536
+ total: bugs.length,
537
+ matched: filtered.length,
538
+ page: safePage,
539
+ limit: safeLimit,
540
+ productId: effectiveProductId,
541
+ projectSetId: effectiveProjectSetId,
542
+ assignedTo: assignee,
543
+ bugs: filtered,
544
+ raw: { status: resp?.status, path: candidate },
545
+ };
546
+
547
+ if (
548
+ !bestResult ||
549
+ currentResult.matched > bestResult.matched ||
550
+ (currentResult.matched === bestResult.matched && currentResult.total > bestResult.total)
551
+ ) {
552
+ bestResult = currentResult;
553
+ }
554
+
555
+ if (currentResult.matched > 0) break;
556
+ } catch (err) {
557
+ lastErr = err;
558
+ triedPaths.push({
559
+ path: candidate,
560
+ status: err?.status ?? null,
561
+ error: String(err?.message || err),
562
+ });
563
+ if (!isNeedProductIdError(err)) continue;
564
+ if (candidate !== primaryPath) continue;
565
+ }
566
+ }
567
+ if (!bestResult) throw lastErr;
342
568
 
343
569
  return {
344
- total: bugs.length,
345
- matched: filtered.length,
346
- page: safePage,
347
- limit: safeLimit,
348
- productId: effectiveProductId,
349
- assignedTo: assignee,
350
- bugs: filtered,
351
- raw: { status: resp?.status, path: primaryPath },
570
+ ...bestResult,
571
+ raw: {
572
+ ...bestResult.raw,
573
+ triedPaths,
574
+ },
352
575
  };
353
576
  }
354
577
 
@@ -379,21 +602,33 @@ export function createZenTaoClient(config) {
379
602
  };
380
603
  }
381
604
 
382
- async function resolveBug({ id, resolution = "fixed", comment = "", path = "/bugs/{id}/resolve" } = {}) {
605
+ async function resolveBug({
606
+ id,
607
+ resolution = "fixed",
608
+ solution = "",
609
+ comment = "",
610
+ path = "/bugs/{id}/resolve",
611
+ } = {}) {
383
612
  const bugId = Number(id);
384
613
  if (!Number.isFinite(bugId) || bugId < 1) {
385
614
  throw new Error("resolveBug requires a valid bug id");
386
615
  }
387
616
 
388
617
  const resolvePath = buildBugResolvePath({ id: bugId, path });
389
- const body = { resolution: String(resolution || "fixed") };
390
- if (comment) body.comment = String(comment);
618
+ const resolvedValue = String(resolution || "fixed");
619
+ const resolvedComment = buildResolutionComment({ solution, comment, resolution: resolvedValue });
620
+ const body = {
621
+ resolution: resolvedValue,
622
+ comment: resolvedComment,
623
+ };
391
624
 
392
625
  const resp = await call({ path: resolvePath, method: "POST", body });
393
626
  return {
394
627
  id: bugId,
395
628
  resolved: true,
396
- resolution: body.resolution,
629
+ resolution: resolvedValue,
630
+ solution: String(solution || "").trim(),
631
+ comment: resolvedComment,
397
632
  raw: { status: resp.status, data: resp.data },
398
633
  };
399
634
  }
@@ -467,15 +702,52 @@ export function createZenTaoClient(config) {
467
702
  };
468
703
  }
469
704
 
705
+ async function commentBug({ id, comment, path = "/bugs/{id}/comment" } = {}) {
706
+ const bugId = Number(id);
707
+ if (!Number.isFinite(bugId) || bugId < 1) {
708
+ throw new Error("commentBug requires a valid bug id");
709
+ }
710
+ const text = String(comment || "").trim();
711
+ if (!text) {
712
+ throw new Error("commentBug requires non-empty comment");
713
+ }
714
+
715
+ const primaryPath = buildBugCommentPath({ id: bugId, path });
716
+ const body = { comment: text };
717
+ try {
718
+ const resp = await call({ path: primaryPath, method: "POST", body });
719
+ return {
720
+ id: bugId,
721
+ commented: true,
722
+ comment: text,
723
+ raw: { status: resp.status, path: primaryPath, data: resp.data },
724
+ };
725
+ } catch (err) {
726
+ const fallbackPath = primaryPath.replace(/\/comment$/, "/comments");
727
+ if (fallbackPath !== primaryPath && Number(err?.status) === 404) {
728
+ const resp = await call({ path: fallbackPath, method: "POST", body });
729
+ return {
730
+ id: bugId,
731
+ commented: true,
732
+ comment: text,
733
+ raw: { status: resp.status, path: fallbackPath, data: resp.data },
734
+ };
735
+ }
736
+ throw err;
737
+ }
738
+ }
739
+
470
740
  async function batchResolveMyBugs({
471
741
  status = "active",
472
742
  keyword = "",
473
743
  limit = 50,
474
744
  page = 1,
475
745
  productId,
746
+ projectSetId,
476
747
  maxItems = 50,
477
748
  assignedTo = "",
478
749
  resolution = "fixed",
750
+ solution = "",
479
751
  comment = "",
480
752
  listPath = "/bugs",
481
753
  resolvePath = "/bugs/{id}/resolve",
@@ -488,6 +760,7 @@ export function createZenTaoClient(config) {
488
760
  limit,
489
761
  page,
490
762
  productId,
763
+ projectSetId,
491
764
  path: listPath,
492
765
  assignedTo,
493
766
  });
@@ -507,6 +780,7 @@ export function createZenTaoClient(config) {
507
780
  const result = await resolveBug({
508
781
  id: bugId,
509
782
  resolution,
783
+ solution,
510
784
  comment,
511
785
  path: resolvePath,
512
786
  });
@@ -552,6 +826,7 @@ export function createZenTaoClient(config) {
552
826
  resolveBug,
553
827
  closeBug,
554
828
  verifyBug,
829
+ commentBug,
555
830
  batchResolveMyBugs,
556
831
  };
557
832
  }