@aipper/zentao-mcp-server 0.1.10 → 0.1.11

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/src/zentao.js CHANGED
@@ -2,6 +2,8 @@
2
2
  const DEFAULT_RESOLUTION_PREFIX = "解决说明:";
3
3
  const DEFAULT_RESOLUTION_FALLBACK = "已处理";
4
4
  const MAX_ERROR_TEXT_LENGTH = 2000;
5
+ const MAX_BATCH_RESOLVE_ITEMS = 100;
6
+ const SAFE_BUG_LIST_PATHS = new Set(["/bugs", "/my/bug", "/my/bugs"]);
5
7
 
6
8
  /**
7
9
  * Sleep for a specified duration
@@ -21,6 +23,30 @@ function isProbablyAbsoluteUrl(value) {
21
23
  return /^https?:\/\//i.test(value);
22
24
  }
23
25
 
26
+ function assertSafeRelativePath(value, fieldName) {
27
+ const raw = String(value || "").trim();
28
+ if (!raw) throw new Error(`${fieldName} is required`);
29
+ if (isProbablyAbsoluteUrl(raw)) {
30
+ throw new Error(`${fieldName} must be a relative path`);
31
+ }
32
+ if (raw.startsWith("//")) {
33
+ throw new Error(`${fieldName} must not be protocol-relative`);
34
+ }
35
+
36
+ const normalized = raw.startsWith("/") ? raw : `/${raw}`;
37
+ if (normalized.includes("?") || normalized.includes("#")) {
38
+ throw new Error(`${fieldName} must not include query or hash`);
39
+ }
40
+ const lowered = normalized.toLowerCase();
41
+ if (lowered.includes("\\") || lowered.includes("%5c") || lowered.includes("%2f")) {
42
+ throw new Error(`${fieldName} must not contain backslashes or encoded separators`);
43
+ }
44
+ if (/(^|\/)\.{1,2}(?:\/|$)/.test(normalized) || lowered.includes("%2e")) {
45
+ throw new Error(`${fieldName} must not contain dot segments`);
46
+ }
47
+ return normalized;
48
+ }
49
+
24
50
  /**
25
51
  * Build a full URL from base, prefix, path and query parameters
26
52
  * @param {Object} params
@@ -31,13 +57,13 @@ function isProbablyAbsoluteUrl(value) {
31
57
  * @returns {URL}
32
58
  */
33
59
  function buildUrl({ baseUrl, apiPrefix, path, query }) {
34
- if (!path) throw new Error("path is required");
35
- if (isProbablyAbsoluteUrl(path)) {
36
- throw new Error("path must be relative (absolute URL is not allowed)");
37
- }
38
- const normalizedPath = path.startsWith("/") ? path : `/${path}`;
39
- const prefix = apiPrefix.startsWith("/") ? apiPrefix : `/${apiPrefix}`;
60
+ const normalizedPath = assertSafeRelativePath(path, "path");
61
+ const prefix = assertSafeRelativePath(apiPrefix, "apiPrefix").replace(/\/+$/, "");
40
62
  const url = new URL(`${baseUrl}${prefix}${normalizedPath}`);
63
+ const allowedPrefix = `${prefix}/`;
64
+ if (url.pathname !== prefix && !url.pathname.startsWith(allowedPrefix)) {
65
+ throw new Error("path escapes apiPrefix");
66
+ }
41
67
 
42
68
  if (query && typeof query === "object") {
43
69
  for (const [k, v] of Object.entries(query)) {
@@ -84,6 +110,7 @@ export function createZenTaoClient(config) {
84
110
  tokenPath,
85
111
  tokenTtlMs,
86
112
  timeoutMs,
113
+ allowInsecureHttp,
87
114
  defaultProductId,
88
115
  defaultProjectSetId,
89
116
  myBugsPath,
@@ -92,23 +119,36 @@ export function createZenTaoClient(config) {
92
119
  auth,
93
120
  } = config;
94
121
 
122
+ const normalizedBaseUrl = String(baseUrl || "").replace(/\/+$/, "");
123
+ const parsedBaseUrl = new URL(normalizedBaseUrl);
124
+ if (parsedBaseUrl.protocol !== "https:" && !(allowInsecureHttp && parsedBaseUrl.protocol === "http:")) {
125
+ throw new Error("baseUrl must use HTTPS unless allowInsecureHttp=true");
126
+ }
127
+ const normalizedApiPrefix = assertSafeRelativePath(apiPrefix, "apiPrefix").replace(/\/+$/, "");
128
+ const normalizedTokenPath = assertSafeRelativePath(tokenPath, "tokenPath");
129
+ const baseOrigin = parsedBaseUrl.origin;
130
+ const baseProtocol = parsedBaseUrl.protocol;
131
+ const basePathname = parsedBaseUrl.pathname.replace(/\/+$/, "");
132
+ const deploymentBaseUrl = `${baseOrigin}${basePathname || ""}/`;
133
+
95
134
  let cachedToken = "";
96
135
  let cachedAt = 0;
97
136
 
98
137
  async function fetchJson(url, { method, headers, body }) {
99
138
  const { signal, cleanup } = createAbortSignal(timeoutMs);
100
139
  try {
101
- const resp = await fetch(url, { method, headers, body, signal });
140
+ const resp = await fetch(url, { method, headers, body, signal, redirect: "error" });
102
141
  const text = await resp.text();
103
142
  const contentType = resp.headers.get("content-type") || "";
104
143
  const data = contentType.includes("application/json") ? safeJsonParse(text) : text;
105
144
  if (!resp.ok) {
106
- const err = new Error(`Request failed ${resp.status}: ${truncate(String(text), MAX_ERROR_TEXT_LENGTH)}`);
145
+ const err = new Error(`Request failed ${resp.status}`);
107
146
  err.status = resp.status;
108
147
  err.data = data;
148
+ err.responseText = truncate(String(text), MAX_ERROR_TEXT_LENGTH);
109
149
  throw err;
110
150
  }
111
- return { status: resp.status, headers: Object.fromEntries(resp.headers.entries()), data };
151
+ return { status: resp.status, data };
112
152
  } finally {
113
153
  cleanup();
114
154
  }
@@ -133,6 +173,12 @@ export function createZenTaoClient(config) {
133
173
  return Date.now() - cachedAt > tokenTtlMs;
134
174
  }
135
175
 
176
+ function resolveAgainstDeploymentPath(path) {
177
+ const normalizedPath = assertSafeRelativePath(path, "path");
178
+ const relativePath = normalizedPath.replace(/^\/+/, "");
179
+ return new URL(relativePath, deploymentBaseUrl);
180
+ }
181
+
136
182
  async function getToken({ force } = {}) {
137
183
  if (!force && !tokenExpired()) {
138
184
  return { token: cachedToken, source: "cache" };
@@ -142,7 +188,7 @@ export function createZenTaoClient(config) {
142
188
  }
143
189
 
144
190
  // 兼容性:不同禅道版本 token 接口可能不同;先按常见 v1 约定尝试
145
- const url = new URL(tokenPath, baseUrl);
191
+ const url = resolveAgainstDeploymentPath(normalizedTokenPath);
146
192
  const payload = { account: auth.account, password: auth.password };
147
193
  const resp = await fetchJson(url, {
148
194
  method: "POST",
@@ -168,7 +214,7 @@ export function createZenTaoClient(config) {
168
214
 
169
215
  async function call({ path, method = "GET", query, body } = {}) {
170
216
  const tokenInfo = await getToken();
171
- const url = buildUrl({ baseUrl, apiPrefix, path, query });
217
+ const url = buildUrl({ baseUrl: normalizedBaseUrl, apiPrefix: normalizedApiPrefix, path, query });
172
218
 
173
219
  const headers = { Token: tokenInfo.token };
174
220
  let payload;
@@ -221,6 +267,30 @@ export function createZenTaoClient(config) {
221
267
  return [];
222
268
  }
223
269
 
270
+ function parseProductsFromResponse(data) {
271
+ if (Array.isArray(data?.products)) return data.products;
272
+ if (Array.isArray(data?.data?.products)) return data.data.products;
273
+ if (Array.isArray(data?.data)) return data.data;
274
+ if (Array.isArray(data)) return data;
275
+ return [];
276
+ }
277
+
278
+ function parseProjectsFromResponse(data) {
279
+ if (Array.isArray(data?.projects)) return data.projects;
280
+ if (Array.isArray(data?.data?.projects)) return data.data.projects;
281
+ if (Array.isArray(data?.data)) return data.data;
282
+ if (Array.isArray(data)) return data;
283
+ return [];
284
+ }
285
+
286
+ function parseProgramsFromResponse(data) {
287
+ if (Array.isArray(data?.programs)) return data.programs;
288
+ if (Array.isArray(data?.data?.programs)) return data.data.programs;
289
+ if (Array.isArray(data?.data)) return data.data;
290
+ if (Array.isArray(data)) return data;
291
+ return [];
292
+ }
293
+
224
294
  function parseBugDetailFromResponse(data) {
225
295
  if (data?.bug && typeof data.bug === "object") return data.bug;
226
296
  if (data?.data?.bug && typeof data.data.bug === "object") return data.data.bug;
@@ -255,7 +325,8 @@ export function createZenTaoClient(config) {
255
325
  function buildMyBugsPath(pathTemplate) {
256
326
  const template = String(pathTemplate || "").trim();
257
327
  if (!template) return "";
258
- return template.replaceAll("{account}", encodeURIComponent(auth.account || ""));
328
+ const resolved = template.replaceAll("{account}", encodeURIComponent(auth.account || ""));
329
+ return assertSafeRelativePath(resolved, "myBugsPath");
259
330
  }
260
331
 
261
332
  function buildProjectSetPath(pathTemplate, projectSetId) {
@@ -263,7 +334,8 @@ export function createZenTaoClient(config) {
263
334
  if (!template) return "";
264
335
  const pid = normalizePositiveInt(projectSetId);
265
336
  if (!pid) return "";
266
- return template.replaceAll("{projectSetId}", String(pid));
337
+ const resolved = template.replaceAll("{projectSetId}", String(pid));
338
+ return assertSafeRelativePath(resolved, "projectSetBugsPath");
267
339
  }
268
340
 
269
341
  function isMyBugsPath(path) {
@@ -286,6 +358,26 @@ export function createZenTaoClient(config) {
286
358
  return merged.includes("need product id");
287
359
  }
288
360
 
361
+ function normalizeBugsListPath(path) {
362
+ const normalizedPath = String(path || "/bugs").trim() || "/bugs";
363
+ if (!SAFE_BUG_LIST_PATHS.has(normalizedPath)) {
364
+ throw new Error("path must be one of /bugs, /my/bug, /my/bugs");
365
+ }
366
+ return normalizedPath;
367
+ }
368
+
369
+ function buildProgramProductsPath(projectSetId) {
370
+ const pid = normalizePositiveInt(projectSetId);
371
+ if (!pid) return "";
372
+ return `/programs/${pid}/products`;
373
+ }
374
+
375
+ function buildProgramProjectsPath(projectSetId) {
376
+ const pid = normalizePositiveInt(projectSetId);
377
+ if (!pid) return "";
378
+ return `/programs/${pid}/projects`;
379
+ }
380
+
289
381
  function buildBugsQueryForPath({ path, limit, page, assignedTo, status, productId }) {
290
382
  if (isMyBugsPath(path) || isProjectSetPath(path)) {
291
383
  // "我的bug"和"项目集bug"类端点在部分实例不接受 assignedTo/status/product 参数,使用最小分页参数后本地过滤。
@@ -316,13 +408,33 @@ export function createZenTaoClient(config) {
316
408
  return `${DEFAULT_RESOLUTION_FALLBACK},resolution=${String(resolution || "fixed")}`;
317
409
  }
318
410
 
411
+ function normalizeUserIdentity(value) {
412
+ if (!value) return "";
413
+ if (typeof value === "string" || typeof value === "number") {
414
+ return String(value);
415
+ }
416
+ if (typeof value === "object") {
417
+ return (
418
+ value.account ||
419
+ value.username ||
420
+ value.userName ||
421
+ value.realname ||
422
+ value.realName ||
423
+ value.name ||
424
+ value.id ||
425
+ ""
426
+ );
427
+ }
428
+ return "";
429
+ }
430
+
319
431
  function getBugAssignee(bug) {
320
- return (
321
- bug?.assignedTo ||
322
- bug?.assignedto ||
323
- bug?.assigned_to ||
324
- bug?.assignedUser ||
325
- bug?.owner ||
432
+ return normalizeUserIdentity(
433
+ bug?.assignedTo ??
434
+ bug?.assignedto ??
435
+ bug?.assigned_to ??
436
+ bug?.assignedUser ??
437
+ bug?.owner ??
326
438
  ""
327
439
  );
328
440
  }
@@ -360,40 +472,25 @@ export function createZenTaoClient(config) {
360
472
  return true;
361
473
  }
362
474
 
363
- function buildBugDetailPath({ id, path }) {
475
+ function buildBugDetailPath({ id }) {
364
476
  const normalizedId = Number(id);
365
- const basePath = path || "/bugs/{id}";
366
- if (basePath.includes("{id}")) {
367
- return basePath.replaceAll("{id}", String(normalizedId));
368
- }
369
- const trimmed = basePath.replace(/\/+$/, "");
370
- return `${trimmed}/${normalizedId}`;
477
+ return `/bugs/${normalizedId}`;
371
478
  }
372
479
 
373
- function buildBugResolvePath({ id, path }) {
374
- return buildBugTransitionPath({ id, path, action: "resolve" });
480
+ function buildBugResolvePath({ id }) {
481
+ return buildBugTransitionPath({ id, action: "resolve" });
375
482
  }
376
483
 
377
- function buildBugCommentPath({ id, path }) {
484
+ function buildBugCommentPath({ id }) {
378
485
  const normalizedId = Number(id);
379
- const basePath = path || "/bugs/{id}/comment";
380
- if (basePath.includes("{id}")) {
381
- return basePath.replaceAll("{id}", String(normalizedId));
382
- }
383
- const trimmed = basePath.replace(/\/+$/, "");
384
- return `${trimmed}/${normalizedId}/comment`;
486
+ return `/bugs/${normalizedId}/comment`;
385
487
  }
386
488
 
387
- function buildBugTransitionPath({ id, path, action }) {
489
+ function buildBugTransitionPath({ id, action }) {
388
490
  const normalizedId = Number(id);
389
491
  const safeAction = String(action || "").trim();
390
492
  if (!safeAction) throw new Error("buildBugTransitionPath requires action");
391
- const basePath = path || `/bugs/{id}/${safeAction}`;
392
- if (basePath.includes("{id}")) {
393
- return basePath.replaceAll("{id}", String(normalizedId));
394
- }
395
- const trimmed = basePath.replace(/\/+$/, "");
396
- return `${trimmed}/${normalizedId}/${safeAction}`;
493
+ return `/bugs/${normalizedId}/${safeAction}`;
397
494
  }
398
495
 
399
496
  function getBugId(bug) {
@@ -412,17 +509,153 @@ export function createZenTaoClient(config) {
412
509
  if (!raw) return "";
413
510
  if (raw.startsWith("data:")) return "";
414
511
  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}`;
512
+ try {
513
+ const candidate = /^https?:\/\//i.test(cleaned)
514
+ ? new URL(cleaned)
515
+ : cleaned.startsWith("//")
516
+ ? new URL(`${baseProtocol}${cleaned}`)
517
+ : cleaned.startsWith("/")
518
+ ? resolveAgainstDeploymentPath(cleaned)
519
+ : resolveAgainstDeploymentPath(`/${cleaned.replace(/^\.?\//, "")}`);
520
+ if (candidate.origin !== baseOrigin) return "";
521
+ return candidate.toString();
522
+ } catch {
523
+ return "";
524
+ }
525
+ }
526
+
527
+ function sanitizeTextContent(value) {
528
+ const text = String(value || "").trim();
529
+ if (!text) return "";
530
+
531
+ return text
532
+ .replace(/<img\b[^>]*>/gi, "")
533
+ .replace(/!\[[^\]]*]\(([^)\s]+)(?:\s+"[^"]*")?\)/gi, "")
534
+ .replace(/https?:\/\/[^\s"'<>]+/gi, (match) => normalizeResourceUrl(match) ? match : "")
535
+ .trim();
536
+ }
537
+
538
+ function hasMeaningfulValue(value) {
539
+ return value !== undefined && value !== null && value !== "";
540
+ }
541
+
542
+ function pickFirstValue(source, aliases) {
543
+ if (!source || typeof source !== "object") return undefined;
544
+ for (const alias of aliases) {
545
+ const value = source[alias];
546
+ if (hasMeaningfulValue(value)) return value;
547
+ }
548
+ return undefined;
549
+ }
550
+
551
+ function assignCanonicalValue(target, fieldName, source, aliases, options = {}) {
552
+ const { sanitizeText = false, transform } = options;
553
+ const rawValue = pickFirstValue(source, aliases);
554
+ if (!hasMeaningfulValue(rawValue)) return;
555
+
556
+ let value = sanitizeText ? sanitizeTextContent(rawValue) : rawValue;
557
+ if (typeof transform === "function") {
558
+ value = transform(value);
559
+ }
560
+ if (hasMeaningfulValue(value)) {
561
+ target[fieldName] = value;
419
562
  }
420
- if (cleaned.startsWith("/")) {
421
- return new URL(cleaned, `${baseUrl}/`).toString();
563
+ }
564
+
565
+ function sanitizeFileRecord(record) {
566
+ if (!record || typeof record !== "object") return null;
567
+
568
+ const safe = {};
569
+ for (const key of [
570
+ "id",
571
+ "title",
572
+ "name",
573
+ "extension",
574
+ "ext",
575
+ "size",
576
+ "mime",
577
+ "contentType",
578
+ "addedBy",
579
+ "addedDate",
580
+ "createdBy",
581
+ "createdDate",
582
+ ]) {
583
+ if (record[key] !== undefined && record[key] !== null && record[key] !== "") {
584
+ safe[key] = record[key];
585
+ }
586
+ }
587
+
588
+ const url = normalizeResourceUrl(
589
+ record.webPath ||
590
+ record.url ||
591
+ record.downloadUrl ||
592
+ record.downloadurl ||
593
+ record.path ||
594
+ record.pathname ||
595
+ record.viewUrl ||
596
+ record.href
597
+ );
598
+ if (url) safe.url = url;
599
+
600
+ return Object.keys(safe).length > 0 ? safe : null;
601
+ }
602
+
603
+ function sanitizeFileCollection(files) {
604
+ if (!files) return [];
605
+ const list = Array.isArray(files) ? files : Object.values(files);
606
+ return list
607
+ .map((item) => sanitizeFileRecord(item))
608
+ .filter(Boolean);
609
+ }
610
+
611
+ function buildSafeBugDetail(bug) {
612
+ if (!bug || typeof bug !== "object") return null;
613
+
614
+ const safeBug = {};
615
+ const bugId = getBugId(bug);
616
+ if (bugId) safeBug.id = bugId;
617
+
618
+ const scalarFieldMappings = [
619
+ ["title", ["title"]],
620
+ ["status", ["status"]],
621
+ ["severity", ["severity"]],
622
+ ["pri", ["pri"]],
623
+ ["type", ["type"]],
624
+ ["openedBy", ["openedBy", "openedby", "opened_by"], { transform: normalizeUserIdentity }],
625
+ ["openedDate", ["openedDate", "openeddate", "opened_date"]],
626
+ ["assignedTo", ["assignedTo", "assignedto", "assigned_to", "assignedUser", "owner"], { transform: normalizeUserIdentity }],
627
+ ["resolvedBy", ["resolvedBy", "resolvedby", "resolved_by"], { transform: normalizeUserIdentity }],
628
+ ["resolvedDate", ["resolvedDate", "resolveddate", "resolved_date"]],
629
+ ["closedBy", ["closedBy", "closedby", "closed_by"], { transform: normalizeUserIdentity }],
630
+ ["closedDate", ["closedDate", "closeddate", "closed_date"]],
631
+ ["product", ["product"]],
632
+ ["project", ["project"]],
633
+ ["projectSet", ["projectSet", "projectset", "project_set", "projectSetId", "projectsetid", "project_set_id"]],
634
+ ["module", ["module"]],
635
+ ["branch", ["branch"]],
636
+ ["story", ["story"]],
637
+ ["task", ["task"]],
638
+ ["deadline", ["deadline"]],
639
+ ["keywords", ["keywords"]],
640
+ ];
641
+ for (const [fieldName, aliases, options] of scalarFieldMappings) {
642
+ assignCanonicalValue(safeBug, fieldName, bug, aliases, options);
422
643
  }
423
- const normalized = cleaned.replace(/^\.?\//, "");
424
- if (!normalized) return "";
425
- return new URL(`/${normalized}`, `${baseUrl}/`).toString();
644
+
645
+ assignCanonicalValue(safeBug, "resolution", bug, ["resolution"], { sanitizeText: true });
646
+ assignCanonicalValue(safeBug, "steps", bug, ["steps", "stepsHtml", "reproStep", "reprostep"], { sanitizeText: true });
647
+ assignCanonicalValue(safeBug, "comment", bug, ["comment"], { sanitizeText: true });
648
+
649
+ const files = sanitizeFileCollection(bug.files);
650
+ if (files.length > 0) safeBug.files = files;
651
+
652
+ const attachments = sanitizeFileCollection(bug.attachments);
653
+ if (attachments.length > 0) safeBug.attachments = attachments;
654
+
655
+ const openedFiles = sanitizeFileCollection(bug.openedFiles);
656
+ if (openedFiles.length > 0) safeBug.openedFiles = openedFiles;
657
+
658
+ return safeBug;
426
659
  }
427
660
 
428
661
  function isImageLikeText(value) {
@@ -517,17 +750,18 @@ export function createZenTaoClient(config) {
517
750
  productId,
518
751
  projectSetId,
519
752
  path = "/bugs",
520
- assignedTo,
521
753
  } = {}) {
522
754
  const safeLimit = Math.max(1, Math.min(Number(limit) || 20, 200));
523
755
  const safePage = Math.max(1, Number(page) || 1);
524
- const assignee = normalizeString(assignedTo) || normalizeString(auth.account);
756
+ const dashboardScanLimit = Math.max(safeLimit, 100);
757
+ const assignee = normalizeString(auth.account);
525
758
  const effectiveProductId = normalizePositiveInt(productId) || normalizePositiveInt(defaultProductId);
526
759
  const effectiveProjectSetId = normalizePositiveInt(projectSetId) || normalizePositiveInt(defaultProjectSetId);
527
- const primaryPath = buildProductScopedBugsPath({ productId: effectiveProductId, path });
760
+ const requestedPath = normalizeBugsListPath(path);
761
+ const primaryPath = buildProductScopedBugsPath({ productId: effectiveProductId, path: requestedPath });
528
762
  const preferProjectSetPath =
529
763
  effectiveProjectSetId &&
530
- (!path || String(path).trim() === "" || String(path).trim() === "/bugs");
764
+ requestedPath === "/bugs";
531
765
  const configuredMyBugsPath = buildMyBugsPath(myBugsPath);
532
766
  const fallbackPathCandidates = (bugsFallbackPaths && bugsFallbackPaths.length > 0)
533
767
  ? bugsFallbackPaths
@@ -605,6 +839,241 @@ export function createZenTaoClient(config) {
605
839
  if (candidate !== primaryPath) continue;
606
840
  }
607
841
  }
842
+
843
+ if (effectiveProjectSetId) {
844
+ const programProductsPath = buildProgramProductsPath(effectiveProjectSetId);
845
+ if (programProductsPath) {
846
+ try {
847
+ const productsResp = await call({
848
+ path: programProductsPath,
849
+ method: "GET",
850
+ query: { limit: safeLimit, page: safePage },
851
+ });
852
+ const products = parseProductsFromResponse(productsResp?.data);
853
+ const productIds = products
854
+ .map((product) => normalizePositiveInt(product?.id ?? product?.productId ?? product?.productID))
855
+ .filter(Boolean);
856
+
857
+ triedPaths.push({
858
+ path: programProductsPath,
859
+ status: productsResp?.status ?? null,
860
+ total: productIds.length,
861
+ matched: 0,
862
+ });
863
+
864
+ for (const scopedProductId of productIds) {
865
+ const productPath = `/products/${scopedProductId}/bugs`;
866
+ try {
867
+ const query = buildBugsQueryForPath({
868
+ path: productPath,
869
+ limit: safeLimit,
870
+ page: safePage,
871
+ assignedTo: assignee,
872
+ status,
873
+ productId: scopedProductId,
874
+ });
875
+ const resp = await call({ path: productPath, method: "GET", query });
876
+ const bugs = parseBugsFromResponse(resp?.data);
877
+ const filtered = bugs.filter((bug) => matchesBugFilters(bug, { status, keyword, assignee }));
878
+
879
+ triedPaths.push({
880
+ path: productPath,
881
+ status: resp?.status ?? null,
882
+ total: bugs.length,
883
+ matched: filtered.length,
884
+ });
885
+
886
+ successful.push({
887
+ path: productPath,
888
+ status: resp?.status ?? null,
889
+ total: bugs.length,
890
+ matched: filtered.length,
891
+ bugs,
892
+ });
893
+ } catch (err) {
894
+ lastErr = err;
895
+ triedPaths.push({
896
+ path: productPath,
897
+ status: err?.status ?? null,
898
+ error: String(err?.message || err),
899
+ });
900
+ }
901
+ }
902
+ } catch (err) {
903
+ lastErr = err;
904
+ triedPaths.push({
905
+ path: programProductsPath,
906
+ status: err?.status ?? null,
907
+ error: String(err?.message || err),
908
+ });
909
+ }
910
+ }
911
+
912
+ const programProjectsPath = buildProgramProjectsPath(effectiveProjectSetId);
913
+ if (programProjectsPath) {
914
+ try {
915
+ const projectsResp = await call({
916
+ path: programProjectsPath,
917
+ method: "GET",
918
+ query: { limit: safeLimit, page: safePage },
919
+ });
920
+ const projects = parseProjectsFromResponse(projectsResp?.data);
921
+ const projectIds = projects
922
+ .map((project) => normalizePositiveInt(project?.id ?? project?.projectId ?? project?.projectID))
923
+ .filter(Boolean);
924
+
925
+ triedPaths.push({
926
+ path: programProjectsPath,
927
+ status: projectsResp?.status ?? null,
928
+ total: projectIds.length,
929
+ matched: 0,
930
+ });
931
+
932
+ for (const scopedProjectId of projectIds) {
933
+ const projectPath = `/projects/${scopedProjectId}/bugs`;
934
+ try {
935
+ const resp = await call({
936
+ path: projectPath,
937
+ method: "GET",
938
+ query: { limit: safeLimit, page: safePage, assignedTo: assignee },
939
+ });
940
+ const bugs = parseBugsFromResponse(resp?.data);
941
+ const filtered = bugs.filter((bug) => matchesBugFilters(bug, { status, keyword, assignee }));
942
+
943
+ triedPaths.push({
944
+ path: projectPath,
945
+ status: resp?.status ?? null,
946
+ total: bugs.length,
947
+ matched: filtered.length,
948
+ });
949
+
950
+ successful.push({
951
+ path: projectPath,
952
+ status: resp?.status ?? null,
953
+ total: bugs.length,
954
+ matched: filtered.length,
955
+ bugs,
956
+ });
957
+ } catch (err) {
958
+ lastErr = err;
959
+ triedPaths.push({
960
+ path: projectPath,
961
+ status: err?.status ?? null,
962
+ error: String(err?.message || err),
963
+ });
964
+ }
965
+ }
966
+ } catch (err) {
967
+ lastErr = err;
968
+ triedPaths.push({
969
+ path: programProjectsPath,
970
+ status: err?.status ?? null,
971
+ error: String(err?.message || err),
972
+ });
973
+ }
974
+ }
975
+ }
976
+
977
+ if (!effectiveProjectSetId && !effectiveProductId && successful.length === 0) {
978
+ try {
979
+ const programsResp = await call({
980
+ path: "/programs",
981
+ method: "GET",
982
+ query: { limit: 100, page: 1 },
983
+ });
984
+ const programs = parseProgramsFromResponse(programsResp?.data);
985
+ const programIds = programs
986
+ .map((program) => normalizePositiveInt(program?.id ?? program?.projectSetId ?? program?.programId))
987
+ .filter(Boolean);
988
+ const scannedProjectIds = new Set();
989
+
990
+ triedPaths.push({
991
+ path: "/programs",
992
+ status: programsResp?.status ?? null,
993
+ total: programIds.length,
994
+ matched: 0,
995
+ });
996
+
997
+ for (const scopedProgramId of programIds) {
998
+ const programProjectsPath = `/programs/${scopedProgramId}/projects`;
999
+ try {
1000
+ const projectsResp = await call({
1001
+ path: programProjectsPath,
1002
+ method: "GET",
1003
+ query: { limit: 100, page: 1 },
1004
+ });
1005
+ const projects = parseProjectsFromResponse(projectsResp?.data);
1006
+ const projectIds = projects
1007
+ .map((project) => normalizePositiveInt(project?.id ?? project?.projectId ?? project?.projectID))
1008
+ .filter(Boolean);
1009
+
1010
+ triedPaths.push({
1011
+ path: programProjectsPath,
1012
+ status: projectsResp?.status ?? null,
1013
+ total: projectIds.length,
1014
+ matched: 0,
1015
+ });
1016
+
1017
+ for (const scopedProjectId of projectIds) {
1018
+ if (scannedProjectIds.has(scopedProjectId)) continue;
1019
+ scannedProjectIds.add(scopedProjectId);
1020
+ const projectPath = `/projects/${scopedProjectId}/bugs`;
1021
+ try {
1022
+ const resp = await call({
1023
+ path: projectPath,
1024
+ method: "GET",
1025
+ query: {
1026
+ limit: dashboardScanLimit,
1027
+ page: safePage,
1028
+ assignedTo: assignee,
1029
+ status: status || undefined,
1030
+ },
1031
+ });
1032
+ const bugs = parseBugsFromResponse(resp?.data);
1033
+ const filtered = bugs.filter((bug) => matchesBugFilters(bug, { status, keyword, assignee }));
1034
+
1035
+ triedPaths.push({
1036
+ path: projectPath,
1037
+ status: resp?.status ?? null,
1038
+ total: bugs.length,
1039
+ matched: filtered.length,
1040
+ });
1041
+
1042
+ successful.push({
1043
+ path: projectPath,
1044
+ status: resp?.status ?? null,
1045
+ total: bugs.length,
1046
+ matched: filtered.length,
1047
+ bugs,
1048
+ });
1049
+ } catch (err) {
1050
+ lastErr = err;
1051
+ triedPaths.push({
1052
+ path: projectPath,
1053
+ status: err?.status ?? null,
1054
+ error: String(err?.message || err),
1055
+ });
1056
+ }
1057
+ }
1058
+ } catch (err) {
1059
+ lastErr = err;
1060
+ triedPaths.push({
1061
+ path: programProjectsPath,
1062
+ status: err?.status ?? null,
1063
+ error: String(err?.message || err),
1064
+ });
1065
+ }
1066
+ }
1067
+ } catch (err) {
1068
+ lastErr = err;
1069
+ triedPaths.push({
1070
+ path: "/programs",
1071
+ status: err?.status ?? null,
1072
+ error: String(err?.message || err),
1073
+ });
1074
+ }
1075
+ }
1076
+
608
1077
  if (successful.length === 0) throw lastErr;
609
1078
 
610
1079
  const mergedBugs = [];
@@ -629,7 +1098,7 @@ export function createZenTaoClient(config) {
629
1098
  }, null);
630
1099
 
631
1100
  return {
632
- total: mergedBugs.length,
1101
+ total: mergedFiltered.length,
633
1102
  matched: mergedFiltered.length,
634
1103
  page: safePage,
635
1104
  limit: safeLimit,
@@ -641,6 +1110,7 @@ export function createZenTaoClient(config) {
641
1110
  status: best?.status ?? null,
642
1111
  path: best?.path ?? primaryPath,
643
1112
  triedPaths,
1113
+ scannedTotal: mergedBugs.length,
644
1114
  paths: successful.map((r) => ({
645
1115
  path: r.path,
646
1116
  status: r.status,
@@ -652,13 +1122,13 @@ export function createZenTaoClient(config) {
652
1122
  };
653
1123
  }
654
1124
 
655
- async function getBugDetail({ id, path = "/bugs/{id}" } = {}) {
1125
+ async function getBugDetail({ id } = {}) {
656
1126
  const bugId = Number(id);
657
1127
  if (!Number.isFinite(bugId) || bugId < 1) {
658
1128
  throw new Error("getBugDetail requires a valid bug id");
659
1129
  }
660
1130
 
661
- const detailPath = buildBugDetailPath({ id: bugId, path });
1131
+ const detailPath = buildBugDetailPath({ id: bugId });
662
1132
  const resp = await call({ path: detailPath, method: "GET" });
663
1133
  const bug = parseBugDetailFromResponse(resp.data);
664
1134
  if (!bug) {
@@ -666,14 +1136,14 @@ export function createZenTaoClient(config) {
666
1136
  id: bugId,
667
1137
  found: false,
668
1138
  images: [],
669
- raw: { status: resp.status, data: resp.data },
1139
+ raw: { status: resp.status },
670
1140
  };
671
1141
  }
672
1142
 
673
1143
  return {
674
1144
  id: bugId,
675
1145
  found: true,
676
- bug,
1146
+ bug: buildSafeBugDetail(bug),
677
1147
  images: extractImageUrlsFromBug(bug),
678
1148
  raw: { status: resp.status },
679
1149
  };
@@ -684,14 +1154,13 @@ export function createZenTaoClient(config) {
684
1154
  resolution = "fixed",
685
1155
  solution = "",
686
1156
  comment = "",
687
- path = "/bugs/{id}/resolve",
688
1157
  } = {}) {
689
1158
  const bugId = Number(id);
690
1159
  if (!Number.isFinite(bugId) || bugId < 1) {
691
1160
  throw new Error("resolveBug requires a valid bug id");
692
1161
  }
693
1162
 
694
- const resolvePath = buildBugResolvePath({ id: bugId, path });
1163
+ const resolvePath = buildBugResolvePath({ id: bugId });
695
1164
  const resolvedValue = String(resolution || "fixed");
696
1165
  const resolvedComment = buildResolutionComment({ solution, comment, resolution: resolvedValue });
697
1166
  const body = {
@@ -706,17 +1175,17 @@ export function createZenTaoClient(config) {
706
1175
  resolution: resolvedValue,
707
1176
  solution: String(solution || "").trim(),
708
1177
  comment: resolvedComment,
709
- raw: { status: resp.status, data: resp.data },
1178
+ raw: { status: resp.status },
710
1179
  };
711
1180
  }
712
1181
 
713
- async function closeBug({ id, comment = "", path = "/bugs/{id}/close" } = {}) {
1182
+ async function closeBug({ id, comment = "" } = {}) {
714
1183
  const bugId = Number(id);
715
1184
  if (!Number.isFinite(bugId) || bugId < 1) {
716
1185
  throw new Error("closeBug requires a valid bug id");
717
1186
  }
718
1187
 
719
- const closePath = buildBugTransitionPath({ id: bugId, path, action: "close" });
1188
+ const closePath = buildBugTransitionPath({ id: bugId, action: "close" });
720
1189
  const body = {};
721
1190
  if (comment) body.comment = String(comment);
722
1191
 
@@ -724,17 +1193,17 @@ export function createZenTaoClient(config) {
724
1193
  return {
725
1194
  id: bugId,
726
1195
  closed: true,
727
- raw: { status: resp.status, data: resp.data },
1196
+ raw: { status: resp.status },
728
1197
  };
729
1198
  }
730
1199
 
731
- async function activateBug({ id, comment = "", path = "/bugs/{id}/activate" } = {}) {
1200
+ async function activateBug({ id, comment = "" } = {}) {
732
1201
  const bugId = Number(id);
733
1202
  if (!Number.isFinite(bugId) || bugId < 1) {
734
1203
  throw new Error("activateBug requires a valid bug id");
735
1204
  }
736
1205
 
737
- const activatePath = buildBugTransitionPath({ id: bugId, path, action: "activate" });
1206
+ const activatePath = buildBugTransitionPath({ id: bugId, action: "activate" });
738
1207
  const body = {};
739
1208
  if (comment) body.comment = String(comment);
740
1209
 
@@ -742,7 +1211,7 @@ export function createZenTaoClient(config) {
742
1211
  return {
743
1212
  id: bugId,
744
1213
  activated: true,
745
- raw: { status: resp.status, data: resp.data },
1214
+ raw: { status: resp.status },
746
1215
  };
747
1216
  }
748
1217
 
@@ -750,8 +1219,6 @@ export function createZenTaoClient(config) {
750
1219
  id,
751
1220
  result = "pass",
752
1221
  comment = "",
753
- closePath = "/bugs/{id}/close",
754
- activatePath = "/bugs/{id}/activate",
755
1222
  } = {}) {
756
1223
  const normalizedResult = normalizeString(result || "pass");
757
1224
  if (normalizedResult !== "pass" && normalizedResult !== "fail") {
@@ -759,7 +1226,7 @@ export function createZenTaoClient(config) {
759
1226
  }
760
1227
 
761
1228
  if (normalizedResult === "pass") {
762
- const closeResult = await closeBug({ id, comment, path: closePath });
1229
+ const closeResult = await closeBug({ id, comment });
763
1230
  return {
764
1231
  id: Number(id),
765
1232
  verified: true,
@@ -769,7 +1236,7 @@ export function createZenTaoClient(config) {
769
1236
  };
770
1237
  }
771
1238
 
772
- const activateResult = await activateBug({ id, comment, path: activatePath });
1239
+ const activateResult = await activateBug({ id, comment });
773
1240
  return {
774
1241
  id: Number(id),
775
1242
  verified: true,
@@ -779,7 +1246,7 @@ export function createZenTaoClient(config) {
779
1246
  };
780
1247
  }
781
1248
 
782
- async function commentBug({ id, comment, path = "/bugs/{id}/comment" } = {}) {
1249
+ async function commentBug({ id, comment } = {}) {
783
1250
  const bugId = Number(id);
784
1251
  if (!Number.isFinite(bugId) || bugId < 1) {
785
1252
  throw new Error("commentBug requires a valid bug id");
@@ -789,7 +1256,7 @@ export function createZenTaoClient(config) {
789
1256
  throw new Error("commentBug requires non-empty comment");
790
1257
  }
791
1258
 
792
- const primaryPath = buildBugCommentPath({ id: bugId, path });
1259
+ const primaryPath = buildBugCommentPath({ id: bugId });
793
1260
  const body = { comment: text };
794
1261
  try {
795
1262
  const resp = await call({ path: primaryPath, method: "POST", body });
@@ -797,7 +1264,7 @@ export function createZenTaoClient(config) {
797
1264
  id: bugId,
798
1265
  commented: true,
799
1266
  comment: text,
800
- raw: { status: resp.status, path: primaryPath, data: resp.data },
1267
+ raw: { status: resp.status, path: primaryPath },
801
1268
  };
802
1269
  } catch (err) {
803
1270
  const fallbackPath = primaryPath.replace(/\/comment$/, "/comments");
@@ -807,7 +1274,7 @@ export function createZenTaoClient(config) {
807
1274
  id: bugId,
808
1275
  commented: true,
809
1276
  comment: text,
810
- raw: { status: resp.status, path: fallbackPath, data: resp.data },
1277
+ raw: { status: resp.status, path: fallbackPath },
811
1278
  };
812
1279
  }
813
1280
  throw err;
@@ -821,16 +1288,14 @@ export function createZenTaoClient(config) {
821
1288
  page = 1,
822
1289
  productId,
823
1290
  projectSetId,
824
- maxItems = 50,
825
- assignedTo = "",
1291
+ maxItems = 20,
826
1292
  resolution = "fixed",
827
1293
  solution = "",
828
1294
  comment = "",
829
- listPath = "/bugs",
830
- resolvePath = "/bugs/{id}/resolve",
831
- stopOnError = false,
1295
+ path = "/bugs",
1296
+ stopOnError = true,
832
1297
  } = {}) {
833
- const safeMaxItems = Math.max(1, Math.min(Number(maxItems) || 50, 500));
1298
+ const safeMaxItems = Math.max(1, Math.min(Number(maxItems) || 20, MAX_BATCH_RESOLVE_ITEMS));
834
1299
  const listResult = await getMyBugs({
835
1300
  status,
836
1301
  keyword,
@@ -838,8 +1303,7 @@ export function createZenTaoClient(config) {
838
1303
  page,
839
1304
  productId,
840
1305
  projectSetId,
841
- path: listPath,
842
- assignedTo,
1306
+ path,
843
1307
  });
844
1308
 
845
1309
  const candidates = (listResult.bugs || []).slice(0, safeMaxItems);
@@ -859,7 +1323,6 @@ export function createZenTaoClient(config) {
859
1323
  resolution,
860
1324
  solution,
861
1325
  comment,
862
- path: resolvePath,
863
1326
  });
864
1327
  success.push({ id: bugId, status: result.raw.status });
865
1328
  } catch (err) {