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