@aipper/zentao-mcp-server 0.1.9 → 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
@@ -1,19 +1,69 @@
1
+ // Constants
2
+ const DEFAULT_RESOLUTION_PREFIX = "解决说明:";
3
+ const DEFAULT_RESOLUTION_FALLBACK = "已处理";
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"]);
7
+
8
+ /**
9
+ * Sleep for a specified duration
10
+ * @param {number} ms - Milliseconds to sleep
11
+ * @returns {Promise<void>}
12
+ */
1
13
  function sleep(ms) {
2
14
  return new Promise((r) => setTimeout(r, ms));
3
15
  }
4
16
 
17
+ /**
18
+ * Check if a value looks like an absolute URL
19
+ * @param {string} value - Value to check
20
+ * @returns {boolean}
21
+ */
5
22
  function isProbablyAbsoluteUrl(value) {
6
23
  return /^https?:\/\//i.test(value);
7
24
  }
8
25
 
9
- function buildUrl({ baseUrl, apiPrefix, path, query }) {
10
- if (!path) throw new Error("path is required");
11
- if (isProbablyAbsoluteUrl(path)) {
12
- throw new Error("path must be relative (absolute URL is not allowed)");
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`);
13
39
  }
14
- const normalizedPath = path.startsWith("/") ? path : `/${path}`;
15
- const prefix = apiPrefix.startsWith("/") ? apiPrefix : `/${apiPrefix}`;
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
+
50
+ /**
51
+ * Build a full URL from base, prefix, path and query parameters
52
+ * @param {Object} params
53
+ * @param {string} params.baseUrl - Base URL
54
+ * @param {string} params.apiPrefix - API prefix
55
+ * @param {string} params.path - Relative path
56
+ * @param {Object} [params.query] - Query parameters
57
+ * @returns {URL}
58
+ */
59
+ function buildUrl({ baseUrl, apiPrefix, path, query }) {
60
+ const normalizedPath = assertSafeRelativePath(path, "path");
61
+ const prefix = assertSafeRelativePath(apiPrefix, "apiPrefix").replace(/\/+$/, "");
16
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
+ }
17
67
 
18
68
  if (query && typeof query === "object") {
19
69
  for (const [k, v] of Object.entries(query)) {
@@ -24,12 +74,35 @@ function buildUrl({ baseUrl, apiPrefix, path, query }) {
24
74
  return url;
25
75
  }
26
76
 
77
+ /**
78
+ * Create an abort signal with timeout
79
+ * @param {number} timeoutMs - Timeout in milliseconds
80
+ * @returns {{signal: AbortSignal, cleanup: Function}}
81
+ */
27
82
  function createAbortSignal(timeoutMs) {
28
83
  const controller = new AbortController();
29
84
  const timer = setTimeout(() => controller.abort(new Error("Request timeout")), timeoutMs);
30
85
  return { signal: controller.signal, cleanup: () => clearTimeout(timer) };
31
86
  }
32
87
 
88
+ /**
89
+ * Create a ZenTao API client
90
+ * @param {Object} config - Client configuration
91
+ * @param {string} config.baseUrl - ZenTao base URL
92
+ * @param {string} config.apiPrefix - API prefix path
93
+ * @param {string} config.tokenPath - Token endpoint path
94
+ * @param {number} config.tokenTtlMs - Token TTL in milliseconds
95
+ * @param {number} config.timeoutMs - Request timeout in milliseconds
96
+ * @param {number} [config.defaultProductId] - Default product ID
97
+ * @param {number} [config.defaultProjectSetId] - Default project set ID
98
+ * @param {string} [config.myBugsPath] - My bugs path
99
+ * @param {string[]} [config.bugsFallbackPaths] - Bug fallback paths
100
+ * @param {string[]} [config.projectSetBugsPaths] - Project set bug paths
101
+ * @param {Object} config.auth - Authentication credentials
102
+ * @param {string} config.auth.account - Account name
103
+ * @param {string} config.auth.password - Account password
104
+ * @returns {Object} ZenTao client instance
105
+ */
33
106
  export function createZenTaoClient(config) {
34
107
  const {
35
108
  baseUrl,
@@ -37,6 +110,7 @@ export function createZenTaoClient(config) {
37
110
  tokenPath,
38
111
  tokenTtlMs,
39
112
  timeoutMs,
113
+ allowInsecureHttp,
40
114
  defaultProductId,
41
115
  defaultProjectSetId,
42
116
  myBugsPath,
@@ -45,23 +119,36 @@ export function createZenTaoClient(config) {
45
119
  auth,
46
120
  } = config;
47
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
+
48
134
  let cachedToken = "";
49
135
  let cachedAt = 0;
50
136
 
51
137
  async function fetchJson(url, { method, headers, body }) {
52
138
  const { signal, cleanup } = createAbortSignal(timeoutMs);
53
139
  try {
54
- const resp = await fetch(url, { method, headers, body, signal });
140
+ const resp = await fetch(url, { method, headers, body, signal, redirect: "error" });
55
141
  const text = await resp.text();
56
142
  const contentType = resp.headers.get("content-type") || "";
57
143
  const data = contentType.includes("application/json") ? safeJsonParse(text) : text;
58
144
  if (!resp.ok) {
59
- const err = new Error(`Request failed ${resp.status}: ${truncate(String(text), 2000)}`);
145
+ const err = new Error(`Request failed ${resp.status}`);
60
146
  err.status = resp.status;
61
147
  err.data = data;
148
+ err.responseText = truncate(String(text), MAX_ERROR_TEXT_LENGTH);
62
149
  throw err;
63
150
  }
64
- return { status: resp.status, headers: Object.fromEntries(resp.headers.entries()), data };
151
+ return { status: resp.status, data };
65
152
  } finally {
66
153
  cleanup();
67
154
  }
@@ -86,6 +173,12 @@ export function createZenTaoClient(config) {
86
173
  return Date.now() - cachedAt > tokenTtlMs;
87
174
  }
88
175
 
176
+ function resolveAgainstDeploymentPath(path) {
177
+ const normalizedPath = assertSafeRelativePath(path, "path");
178
+ const relativePath = normalizedPath.replace(/^\/+/, "");
179
+ return new URL(relativePath, deploymentBaseUrl);
180
+ }
181
+
89
182
  async function getToken({ force } = {}) {
90
183
  if (!force && !tokenExpired()) {
91
184
  return { token: cachedToken, source: "cache" };
@@ -95,7 +188,7 @@ export function createZenTaoClient(config) {
95
188
  }
96
189
 
97
190
  // 兼容性:不同禅道版本 token 接口可能不同;先按常见 v1 约定尝试
98
- const url = new URL(tokenPath, baseUrl);
191
+ const url = resolveAgainstDeploymentPath(normalizedTokenPath);
99
192
  const payload = { account: auth.account, password: auth.password };
100
193
  const resp = await fetchJson(url, {
101
194
  method: "POST",
@@ -121,7 +214,7 @@ export function createZenTaoClient(config) {
121
214
 
122
215
  async function call({ path, method = "GET", query, body } = {}) {
123
216
  const tokenInfo = await getToken();
124
- const url = buildUrl({ baseUrl, apiPrefix, path, query });
217
+ const url = buildUrl({ baseUrl: normalizedBaseUrl, apiPrefix: normalizedApiPrefix, path, query });
125
218
 
126
219
  const headers = { Token: tokenInfo.token };
127
220
  let payload;
@@ -174,6 +267,30 @@ export function createZenTaoClient(config) {
174
267
  return [];
175
268
  }
176
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
+
177
294
  function parseBugDetailFromResponse(data) {
178
295
  if (data?.bug && typeof data.bug === "object") return data.bug;
179
296
  if (data?.data?.bug && typeof data.data.bug === "object") return data.data.bug;
@@ -208,7 +325,8 @@ export function createZenTaoClient(config) {
208
325
  function buildMyBugsPath(pathTemplate) {
209
326
  const template = String(pathTemplate || "").trim();
210
327
  if (!template) return "";
211
- return template.replaceAll("{account}", encodeURIComponent(auth.account || ""));
328
+ const resolved = template.replaceAll("{account}", encodeURIComponent(auth.account || ""));
329
+ return assertSafeRelativePath(resolved, "myBugsPath");
212
330
  }
213
331
 
214
332
  function buildProjectSetPath(pathTemplate, projectSetId) {
@@ -216,7 +334,8 @@ export function createZenTaoClient(config) {
216
334
  if (!template) return "";
217
335
  const pid = normalizePositiveInt(projectSetId);
218
336
  if (!pid) return "";
219
- return template.replaceAll("{projectSetId}", String(pid));
337
+ const resolved = template.replaceAll("{projectSetId}", String(pid));
338
+ return assertSafeRelativePath(resolved, "projectSetBugsPath");
220
339
  }
221
340
 
222
341
  function isMyBugsPath(path) {
@@ -239,6 +358,26 @@ export function createZenTaoClient(config) {
239
358
  return merged.includes("need product id");
240
359
  }
241
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
+
242
381
  function buildBugsQueryForPath({ path, limit, page, assignedTo, status, productId }) {
243
382
  if (isMyBugsPath(path) || isProjectSetPath(path)) {
244
383
  // "我的bug"和"项目集bug"类端点在部分实例不接受 assignedTo/status/product 参数,使用最小分页参数后本地过滤。
@@ -253,21 +392,49 @@ export function createZenTaoClient(config) {
253
392
  };
254
393
  }
255
394
 
395
+ /**
396
+ * Build resolution comment from solution, comment or resolution
397
+ * @param {Object} params
398
+ * @param {string} [params.solution] - Solution description (preferred)
399
+ * @param {string} [params.comment] - Comment text
400
+ * @param {string} [params.resolution] - Resolution type
401
+ * @returns {string} Formatted comment
402
+ */
256
403
  function buildResolutionComment({ solution, comment, resolution }) {
257
404
  const normalizedSolution = String(solution || "").trim();
258
- if (normalizedSolution) return `解决说明:${normalizedSolution}`;
405
+ if (normalizedSolution) return `${DEFAULT_RESOLUTION_PREFIX}${normalizedSolution}`;
259
406
  const normalizedComment = String(comment || "").trim();
260
407
  if (normalizedComment) return normalizedComment;
261
- return `已处理,resolution=${String(resolution || "fixed")}`;
408
+ return `${DEFAULT_RESOLUTION_FALLBACK},resolution=${String(resolution || "fixed")}`;
409
+ }
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 "";
262
429
  }
263
430
 
264
431
  function getBugAssignee(bug) {
265
- return (
266
- bug?.assignedTo ||
267
- bug?.assignedto ||
268
- bug?.assigned_to ||
269
- bug?.assignedUser ||
270
- bug?.owner ||
432
+ return normalizeUserIdentity(
433
+ bug?.assignedTo ??
434
+ bug?.assignedto ??
435
+ bug?.assigned_to ??
436
+ bug?.assignedUser ??
437
+ bug?.owner ??
271
438
  ""
272
439
  );
273
440
  }
@@ -305,40 +472,25 @@ export function createZenTaoClient(config) {
305
472
  return true;
306
473
  }
307
474
 
308
- function buildBugDetailPath({ id, path }) {
475
+ function buildBugDetailPath({ id }) {
309
476
  const normalizedId = Number(id);
310
- const basePath = path || "/bugs/{id}";
311
- if (basePath.includes("{id}")) {
312
- return basePath.replaceAll("{id}", String(normalizedId));
313
- }
314
- const trimmed = basePath.replace(/\/+$/, "");
315
- return `${trimmed}/${normalizedId}`;
477
+ return `/bugs/${normalizedId}`;
316
478
  }
317
479
 
318
- function buildBugResolvePath({ id, path }) {
319
- return buildBugTransitionPath({ id, path, action: "resolve" });
480
+ function buildBugResolvePath({ id }) {
481
+ return buildBugTransitionPath({ id, action: "resolve" });
320
482
  }
321
483
 
322
- function buildBugCommentPath({ id, path }) {
484
+ function buildBugCommentPath({ id }) {
323
485
  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`;
486
+ return `/bugs/${normalizedId}/comment`;
330
487
  }
331
488
 
332
- function buildBugTransitionPath({ id, path, action }) {
489
+ function buildBugTransitionPath({ id, action }) {
333
490
  const normalizedId = Number(id);
334
491
  const safeAction = String(action || "").trim();
335
492
  if (!safeAction) throw new Error("buildBugTransitionPath requires action");
336
- const basePath = path || `/bugs/{id}/${safeAction}`;
337
- if (basePath.includes("{id}")) {
338
- return basePath.replaceAll("{id}", String(normalizedId));
339
- }
340
- const trimmed = basePath.replace(/\/+$/, "");
341
- return `${trimmed}/${normalizedId}/${safeAction}`;
493
+ return `/bugs/${normalizedId}/${safeAction}`;
342
494
  }
343
495
 
344
496
  function getBugId(bug) {
@@ -357,17 +509,153 @@ export function createZenTaoClient(config) {
357
509
  if (!raw) return "";
358
510
  if (raw.startsWith("data:")) return "";
359
511
  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}`;
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);
364
559
  }
365
- if (cleaned.startsWith("/")) {
366
- return new URL(cleaned, `${baseUrl}/`).toString();
560
+ if (hasMeaningfulValue(value)) {
561
+ target[fieldName] = value;
367
562
  }
368
- const normalized = cleaned.replace(/^\.?\//, "");
369
- if (!normalized) return "";
370
- return new URL(`/${normalized}`, `${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);
643
+ }
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;
371
659
  }
372
660
 
373
661
  function isImageLikeText(value) {
@@ -462,17 +750,18 @@ export function createZenTaoClient(config) {
462
750
  productId,
463
751
  projectSetId,
464
752
  path = "/bugs",
465
- assignedTo,
466
753
  } = {}) {
467
754
  const safeLimit = Math.max(1, Math.min(Number(limit) || 20, 200));
468
755
  const safePage = Math.max(1, Number(page) || 1);
469
- const assignee = normalizeString(assignedTo) || normalizeString(auth.account);
756
+ const dashboardScanLimit = Math.max(safeLimit, 100);
757
+ const assignee = normalizeString(auth.account);
470
758
  const effectiveProductId = normalizePositiveInt(productId) || normalizePositiveInt(defaultProductId);
471
759
  const effectiveProjectSetId = normalizePositiveInt(projectSetId) || normalizePositiveInt(defaultProjectSetId);
472
- const primaryPath = buildProductScopedBugsPath({ productId: effectiveProductId, path });
760
+ const requestedPath = normalizeBugsListPath(path);
761
+ const primaryPath = buildProductScopedBugsPath({ productId: effectiveProductId, path: requestedPath });
473
762
  const preferProjectSetPath =
474
763
  effectiveProjectSetId &&
475
- (!path || String(path).trim() === "" || String(path).trim() === "/bugs");
764
+ requestedPath === "/bugs";
476
765
  const configuredMyBugsPath = buildMyBugsPath(myBugsPath);
477
766
  const fallbackPathCandidates = (bugsFallbackPaths && bugsFallbackPaths.length > 0)
478
767
  ? bugsFallbackPaths
@@ -515,9 +804,9 @@ export function createZenTaoClient(config) {
515
804
  if (!candidatePaths.includes(fallback)) candidatePaths.push(fallback);
516
805
  }
517
806
 
518
- let bestResult = null;
519
807
  let lastErr = null;
520
808
  const triedPaths = [];
809
+ const successful = [];
521
810
  for (const candidate of candidatePaths) {
522
811
  try {
523
812
  const query = buildBugsQueryForPath({ path: candidate, ...baseQuery });
@@ -532,27 +821,13 @@ export function createZenTaoClient(config) {
532
821
  matched: filtered.length,
533
822
  });
534
823
 
535
- const currentResult = {
824
+ successful.push({
825
+ path: candidate,
826
+ status: resp?.status ?? null,
536
827
  total: bugs.length,
537
828
  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;
829
+ bugs,
830
+ });
556
831
  } catch (err) {
557
832
  lastErr = err;
558
833
  triedPaths.push({
@@ -564,24 +839,296 @@ export function createZenTaoClient(config) {
564
839
  if (candidate !== primaryPath) continue;
565
840
  }
566
841
  }
567
- if (!bestResult) throw lastErr;
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
+
1077
+ if (successful.length === 0) throw lastErr;
1078
+
1079
+ const mergedBugs = [];
1080
+ const seenBugIds = new Set();
1081
+ for (const result of successful) {
1082
+ for (const bug of result.bugs || []) {
1083
+ const bugId = getBugId(bug);
1084
+ if (bugId) {
1085
+ if (seenBugIds.has(bugId)) continue;
1086
+ seenBugIds.add(bugId);
1087
+ }
1088
+ mergedBugs.push(bug);
1089
+ }
1090
+ }
1091
+
1092
+ const mergedFiltered = mergedBugs.filter((bug) => matchesBugFilters(bug, { status, keyword, assignee }));
1093
+ const best = successful.reduce((acc, cur) => {
1094
+ if (!acc) return cur;
1095
+ if (cur.matched > acc.matched) return cur;
1096
+ if (cur.matched === acc.matched && cur.total > acc.total) return cur;
1097
+ return acc;
1098
+ }, null);
568
1099
 
569
1100
  return {
570
- ...bestResult,
1101
+ total: mergedFiltered.length,
1102
+ matched: mergedFiltered.length,
1103
+ page: safePage,
1104
+ limit: safeLimit,
1105
+ productId: effectiveProductId,
1106
+ projectSetId: effectiveProjectSetId,
1107
+ assignedTo: assignee,
1108
+ bugs: mergedFiltered,
571
1109
  raw: {
572
- ...bestResult.raw,
1110
+ status: best?.status ?? null,
1111
+ path: best?.path ?? primaryPath,
573
1112
  triedPaths,
1113
+ scannedTotal: mergedBugs.length,
1114
+ paths: successful.map((r) => ({
1115
+ path: r.path,
1116
+ status: r.status,
1117
+ total: r.total,
1118
+ matched: r.matched,
1119
+ })),
1120
+ merged: successful.length > 1,
574
1121
  },
575
1122
  };
576
1123
  }
577
1124
 
578
- async function getBugDetail({ id, path = "/bugs/{id}" } = {}) {
1125
+ async function getBugDetail({ id } = {}) {
579
1126
  const bugId = Number(id);
580
1127
  if (!Number.isFinite(bugId) || bugId < 1) {
581
1128
  throw new Error("getBugDetail requires a valid bug id");
582
1129
  }
583
1130
 
584
- const detailPath = buildBugDetailPath({ id: bugId, path });
1131
+ const detailPath = buildBugDetailPath({ id: bugId });
585
1132
  const resp = await call({ path: detailPath, method: "GET" });
586
1133
  const bug = parseBugDetailFromResponse(resp.data);
587
1134
  if (!bug) {
@@ -589,14 +1136,14 @@ export function createZenTaoClient(config) {
589
1136
  id: bugId,
590
1137
  found: false,
591
1138
  images: [],
592
- raw: { status: resp.status, data: resp.data },
1139
+ raw: { status: resp.status },
593
1140
  };
594
1141
  }
595
1142
 
596
1143
  return {
597
1144
  id: bugId,
598
1145
  found: true,
599
- bug,
1146
+ bug: buildSafeBugDetail(bug),
600
1147
  images: extractImageUrlsFromBug(bug),
601
1148
  raw: { status: resp.status },
602
1149
  };
@@ -607,14 +1154,13 @@ export function createZenTaoClient(config) {
607
1154
  resolution = "fixed",
608
1155
  solution = "",
609
1156
  comment = "",
610
- path = "/bugs/{id}/resolve",
611
1157
  } = {}) {
612
1158
  const bugId = Number(id);
613
1159
  if (!Number.isFinite(bugId) || bugId < 1) {
614
1160
  throw new Error("resolveBug requires a valid bug id");
615
1161
  }
616
1162
 
617
- const resolvePath = buildBugResolvePath({ id: bugId, path });
1163
+ const resolvePath = buildBugResolvePath({ id: bugId });
618
1164
  const resolvedValue = String(resolution || "fixed");
619
1165
  const resolvedComment = buildResolutionComment({ solution, comment, resolution: resolvedValue });
620
1166
  const body = {
@@ -629,17 +1175,17 @@ export function createZenTaoClient(config) {
629
1175
  resolution: resolvedValue,
630
1176
  solution: String(solution || "").trim(),
631
1177
  comment: resolvedComment,
632
- raw: { status: resp.status, data: resp.data },
1178
+ raw: { status: resp.status },
633
1179
  };
634
1180
  }
635
1181
 
636
- async function closeBug({ id, comment = "", path = "/bugs/{id}/close" } = {}) {
1182
+ async function closeBug({ id, comment = "" } = {}) {
637
1183
  const bugId = Number(id);
638
1184
  if (!Number.isFinite(bugId) || bugId < 1) {
639
1185
  throw new Error("closeBug requires a valid bug id");
640
1186
  }
641
1187
 
642
- const closePath = buildBugTransitionPath({ id: bugId, path, action: "close" });
1188
+ const closePath = buildBugTransitionPath({ id: bugId, action: "close" });
643
1189
  const body = {};
644
1190
  if (comment) body.comment = String(comment);
645
1191
 
@@ -647,17 +1193,17 @@ export function createZenTaoClient(config) {
647
1193
  return {
648
1194
  id: bugId,
649
1195
  closed: true,
650
- raw: { status: resp.status, data: resp.data },
1196
+ raw: { status: resp.status },
651
1197
  };
652
1198
  }
653
1199
 
654
- async function activateBug({ id, comment = "", path = "/bugs/{id}/activate" } = {}) {
1200
+ async function activateBug({ id, comment = "" } = {}) {
655
1201
  const bugId = Number(id);
656
1202
  if (!Number.isFinite(bugId) || bugId < 1) {
657
1203
  throw new Error("activateBug requires a valid bug id");
658
1204
  }
659
1205
 
660
- const activatePath = buildBugTransitionPath({ id: bugId, path, action: "activate" });
1206
+ const activatePath = buildBugTransitionPath({ id: bugId, action: "activate" });
661
1207
  const body = {};
662
1208
  if (comment) body.comment = String(comment);
663
1209
 
@@ -665,7 +1211,7 @@ export function createZenTaoClient(config) {
665
1211
  return {
666
1212
  id: bugId,
667
1213
  activated: true,
668
- raw: { status: resp.status, data: resp.data },
1214
+ raw: { status: resp.status },
669
1215
  };
670
1216
  }
671
1217
 
@@ -673,8 +1219,6 @@ export function createZenTaoClient(config) {
673
1219
  id,
674
1220
  result = "pass",
675
1221
  comment = "",
676
- closePath = "/bugs/{id}/close",
677
- activatePath = "/bugs/{id}/activate",
678
1222
  } = {}) {
679
1223
  const normalizedResult = normalizeString(result || "pass");
680
1224
  if (normalizedResult !== "pass" && normalizedResult !== "fail") {
@@ -682,7 +1226,7 @@ export function createZenTaoClient(config) {
682
1226
  }
683
1227
 
684
1228
  if (normalizedResult === "pass") {
685
- const closeResult = await closeBug({ id, comment, path: closePath });
1229
+ const closeResult = await closeBug({ id, comment });
686
1230
  return {
687
1231
  id: Number(id),
688
1232
  verified: true,
@@ -692,7 +1236,7 @@ export function createZenTaoClient(config) {
692
1236
  };
693
1237
  }
694
1238
 
695
- const activateResult = await activateBug({ id, comment, path: activatePath });
1239
+ const activateResult = await activateBug({ id, comment });
696
1240
  return {
697
1241
  id: Number(id),
698
1242
  verified: true,
@@ -702,7 +1246,7 @@ export function createZenTaoClient(config) {
702
1246
  };
703
1247
  }
704
1248
 
705
- async function commentBug({ id, comment, path = "/bugs/{id}/comment" } = {}) {
1249
+ async function commentBug({ id, comment } = {}) {
706
1250
  const bugId = Number(id);
707
1251
  if (!Number.isFinite(bugId) || bugId < 1) {
708
1252
  throw new Error("commentBug requires a valid bug id");
@@ -712,7 +1256,7 @@ export function createZenTaoClient(config) {
712
1256
  throw new Error("commentBug requires non-empty comment");
713
1257
  }
714
1258
 
715
- const primaryPath = buildBugCommentPath({ id: bugId, path });
1259
+ const primaryPath = buildBugCommentPath({ id: bugId });
716
1260
  const body = { comment: text };
717
1261
  try {
718
1262
  const resp = await call({ path: primaryPath, method: "POST", body });
@@ -720,7 +1264,7 @@ export function createZenTaoClient(config) {
720
1264
  id: bugId,
721
1265
  commented: true,
722
1266
  comment: text,
723
- raw: { status: resp.status, path: primaryPath, data: resp.data },
1267
+ raw: { status: resp.status, path: primaryPath },
724
1268
  };
725
1269
  } catch (err) {
726
1270
  const fallbackPath = primaryPath.replace(/\/comment$/, "/comments");
@@ -730,7 +1274,7 @@ export function createZenTaoClient(config) {
730
1274
  id: bugId,
731
1275
  commented: true,
732
1276
  comment: text,
733
- raw: { status: resp.status, path: fallbackPath, data: resp.data },
1277
+ raw: { status: resp.status, path: fallbackPath },
734
1278
  };
735
1279
  }
736
1280
  throw err;
@@ -744,16 +1288,14 @@ export function createZenTaoClient(config) {
744
1288
  page = 1,
745
1289
  productId,
746
1290
  projectSetId,
747
- maxItems = 50,
748
- assignedTo = "",
1291
+ maxItems = 20,
749
1292
  resolution = "fixed",
750
1293
  solution = "",
751
1294
  comment = "",
752
- listPath = "/bugs",
753
- resolvePath = "/bugs/{id}/resolve",
754
- stopOnError = false,
1295
+ path = "/bugs",
1296
+ stopOnError = true,
755
1297
  } = {}) {
756
- 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));
757
1299
  const listResult = await getMyBugs({
758
1300
  status,
759
1301
  keyword,
@@ -761,8 +1303,7 @@ export function createZenTaoClient(config) {
761
1303
  page,
762
1304
  productId,
763
1305
  projectSetId,
764
- path: listPath,
765
- assignedTo,
1306
+ path,
766
1307
  });
767
1308
 
768
1309
  const candidates = (listResult.bugs || []).slice(0, safeMaxItems);
@@ -782,7 +1323,6 @@ export function createZenTaoClient(config) {
782
1323
  resolution,
783
1324
  solution,
784
1325
  comment,
785
- path: resolvePath,
786
1326
  });
787
1327
  success.push({ id: bugId, status: result.raw.status });
788
1328
  } catch (err) {