@aipper/zentao-mcp-server 0.1.5 → 0.1.7

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