@leeguoo/zentao-mcp 0.4.1 → 0.5.1
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/README.md +93 -139
- package/package.json +13 -13
- package/skills/zentao-cli.md +130 -0
- package/src/cli/args.js +99 -0
- package/src/cli/help.js +49 -0
- package/src/commands/bug.js +69 -0
- package/src/commands/bugs.js +138 -0
- package/src/commands/login.js +60 -0
- package/src/commands/products.js +58 -0
- package/src/commands/release.js +198 -0
- package/src/commands/selftest.js +52 -0
- package/src/commands/whoami.js +32 -0
- package/src/config/store.js +81 -0
- package/src/index.js +49 -453
- package/src/zentao/client.js +324 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { getOption, parseCliArgs } from "../cli/args.js";
|
|
2
|
+
import { loadConfig } from "../config/store.js";
|
|
3
|
+
|
|
4
|
+
function normalizeBaseUrl(url) {
|
|
5
|
+
return url.replace(/\/+$/, "");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function toInt(value, fallback) {
|
|
9
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
10
|
+
const parsed = Number(value);
|
|
11
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeResult(payload) {
|
|
15
|
+
return { status: 1, msg: "success", result: payload };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeError(message, payload) {
|
|
19
|
+
return { status: 0, msg: message || "error", result: payload ?? [] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeAccountValue(value) {
|
|
23
|
+
return String(value || "").trim().toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractAccounts(value) {
|
|
27
|
+
if (value === undefined || value === null) return [];
|
|
28
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
29
|
+
const normalized = normalizeAccountValue(value);
|
|
30
|
+
return normalized ? [normalized] : [];
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return value.flatMap((item) => extractAccounts(item));
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === "object") {
|
|
36
|
+
if (value.account) return extractAccounts(value.account);
|
|
37
|
+
if (value.user) return extractAccounts(value.user);
|
|
38
|
+
if (value.name) return extractAccounts(value.name);
|
|
39
|
+
if (value.realname) return extractAccounts(value.realname);
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function matchesAccount(value, matchAccount) {
|
|
46
|
+
const candidates = extractAccounts(value);
|
|
47
|
+
return candidates.includes(matchAccount);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class ZentaoClient {
|
|
51
|
+
constructor({ baseUrl, account, password }) {
|
|
52
|
+
this.baseUrl = normalizeBaseUrl(baseUrl);
|
|
53
|
+
this.account = account;
|
|
54
|
+
this.password = password;
|
|
55
|
+
this.token = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async ensureToken() {
|
|
59
|
+
if (this.token) return;
|
|
60
|
+
this.token = await this.getToken();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async getToken() {
|
|
64
|
+
const url = `${this.baseUrl}/api.php/v1/tokens`;
|
|
65
|
+
const res = await fetch(url, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "Content-Type": "application/json" },
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
account: this.account,
|
|
70
|
+
password: this.password,
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
let json;
|
|
76
|
+
try {
|
|
77
|
+
json = JSON.parse(text);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`Token response parse failed: ${text.slice(0, 200)}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (json.error) {
|
|
83
|
+
throw new Error(`Token request failed: ${json.error}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!json.token) {
|
|
87
|
+
throw new Error(`Token missing in response: ${text.slice(0, 200)}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return json.token;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async request({ method, path, query = {}, body }) {
|
|
94
|
+
await this.ensureToken();
|
|
95
|
+
|
|
96
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
97
|
+
Object.entries(query).forEach(([key, value]) => {
|
|
98
|
+
if (value === undefined || value === null) return;
|
|
99
|
+
url.searchParams.set(key, String(value));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const headers = {
|
|
103
|
+
Token: this.token,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const options = { method, headers };
|
|
107
|
+
|
|
108
|
+
if (body !== undefined) {
|
|
109
|
+
headers["Content-Type"] = "application/json";
|
|
110
|
+
options.body = JSON.stringify(body);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const res = await fetch(url, options);
|
|
114
|
+
const text = await res.text();
|
|
115
|
+
let json;
|
|
116
|
+
try {
|
|
117
|
+
json = JSON.parse(text);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new Error(`Response parse failed: ${text.slice(0, 200)}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return json;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async listProducts({ page, limit }) {
|
|
126
|
+
const payload = await this.request({
|
|
127
|
+
method: "GET",
|
|
128
|
+
path: "/api.php/v1/products",
|
|
129
|
+
query: {
|
|
130
|
+
page: toInt(page, 1),
|
|
131
|
+
limit: toInt(limit, 1000),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (payload.error) return normalizeError(payload.error, payload);
|
|
136
|
+
return normalizeResult(payload);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async listBugs({ product, page, limit }) {
|
|
140
|
+
if (!product) throw new Error("product is required");
|
|
141
|
+
|
|
142
|
+
const payload = await this.request({
|
|
143
|
+
method: "GET",
|
|
144
|
+
path: "/api.php/v1/bugs",
|
|
145
|
+
query: {
|
|
146
|
+
product,
|
|
147
|
+
page: toInt(page, 1),
|
|
148
|
+
limit: toInt(limit, 20),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (payload.error) return normalizeError(payload.error, payload);
|
|
153
|
+
return normalizeResult(payload);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async getBug({ id }) {
|
|
157
|
+
if (!id) throw new Error("id is required");
|
|
158
|
+
|
|
159
|
+
const payload = await this.request({
|
|
160
|
+
method: "GET",
|
|
161
|
+
path: `/api.php/v1/bugs/${id}`,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (payload.error) return normalizeError(payload.error, payload);
|
|
165
|
+
return normalizeResult(payload);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async fetchAllBugsForProduct({ product, perPage, maxItems }) {
|
|
169
|
+
const bugs = [];
|
|
170
|
+
let page = 1;
|
|
171
|
+
let total = null;
|
|
172
|
+
const pageSize = toInt(perPage, 100);
|
|
173
|
+
const cap = toInt(maxItems, 0);
|
|
174
|
+
|
|
175
|
+
while (true) {
|
|
176
|
+
const payload = await this.request({
|
|
177
|
+
method: "GET",
|
|
178
|
+
path: "/api.php/v1/bugs",
|
|
179
|
+
query: {
|
|
180
|
+
product,
|
|
181
|
+
page,
|
|
182
|
+
limit: pageSize,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (payload.error) {
|
|
187
|
+
throw new Error(payload.error);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const pageBugs = Array.isArray(payload.bugs) ? payload.bugs : [];
|
|
191
|
+
total = payload.total ?? total;
|
|
192
|
+
for (const bug of pageBugs) {
|
|
193
|
+
bugs.push(bug);
|
|
194
|
+
if (cap > 0 && bugs.length >= cap) {
|
|
195
|
+
return { bugs, total };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (total !== null && payload.limit) {
|
|
200
|
+
if (page * payload.limit >= total) break;
|
|
201
|
+
} else if (pageBugs.length < pageSize) {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
page += 1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { bugs, total };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async bugsMine({
|
|
212
|
+
account,
|
|
213
|
+
scope,
|
|
214
|
+
status,
|
|
215
|
+
productIds,
|
|
216
|
+
includeZero,
|
|
217
|
+
perPage,
|
|
218
|
+
maxItems,
|
|
219
|
+
includeDetails,
|
|
220
|
+
}) {
|
|
221
|
+
const matchAccount = normalizeAccountValue(account || this.account);
|
|
222
|
+
const targetScope = (scope || "assigned").toLowerCase();
|
|
223
|
+
const rawStatus = status ?? "active";
|
|
224
|
+
const statusList = Array.isArray(rawStatus) ? rawStatus : String(rawStatus).split(/[|,]/);
|
|
225
|
+
const statusSet = new Set(
|
|
226
|
+
statusList.map((item) => String(item).trim().toLowerCase()).filter(Boolean)
|
|
227
|
+
);
|
|
228
|
+
const allowAllStatus = statusSet.has("all") || statusSet.size === 0;
|
|
229
|
+
|
|
230
|
+
const productsResponse = await this.listProducts({ page: 1, limit: 1000 });
|
|
231
|
+
if (productsResponse.status !== 1) return productsResponse;
|
|
232
|
+
const products = productsResponse.result.products || [];
|
|
233
|
+
|
|
234
|
+
const productSet = Array.isArray(productIds) && productIds.length
|
|
235
|
+
? new Set(productIds.map((id) => Number(id)))
|
|
236
|
+
: null;
|
|
237
|
+
|
|
238
|
+
const rows = [];
|
|
239
|
+
const bugs = [];
|
|
240
|
+
let totalMatches = 0;
|
|
241
|
+
const maxCollect = toInt(maxItems, 200);
|
|
242
|
+
|
|
243
|
+
for (const product of products) {
|
|
244
|
+
if (productSet && !productSet.has(Number(product.id))) continue;
|
|
245
|
+
const { bugs: productBugs } = await this.fetchAllBugsForProduct({
|
|
246
|
+
product: product.id,
|
|
247
|
+
perPage,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const matches = productBugs.filter((bug) => {
|
|
251
|
+
if (!allowAllStatus) {
|
|
252
|
+
const bugStatus = String(bug.status || "").trim().toLowerCase();
|
|
253
|
+
if (!statusSet.has(bugStatus)) return false;
|
|
254
|
+
}
|
|
255
|
+
const assigned = matchesAccount(bug.assignedTo, matchAccount);
|
|
256
|
+
const opened = matchesAccount(bug.openedBy, matchAccount);
|
|
257
|
+
const resolved = matchesAccount(bug.resolvedBy, matchAccount);
|
|
258
|
+
if (targetScope === "assigned") return assigned;
|
|
259
|
+
if (targetScope === "opened") return opened;
|
|
260
|
+
if (targetScope === "resolved") return resolved;
|
|
261
|
+
return assigned || opened || resolved;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (!includeZero && matches.length === 0) continue;
|
|
265
|
+
totalMatches += matches.length;
|
|
266
|
+
|
|
267
|
+
rows.push({
|
|
268
|
+
id: product.id,
|
|
269
|
+
name: product.name,
|
|
270
|
+
totalBugs: toInt(product.totalBugs, 0),
|
|
271
|
+
myBugs: matches.length,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (includeDetails && bugs.length < maxCollect) {
|
|
275
|
+
for (const bug of matches) {
|
|
276
|
+
if (bugs.length >= maxCollect) break;
|
|
277
|
+
bugs.push({
|
|
278
|
+
id: bug.id,
|
|
279
|
+
title: bug.title,
|
|
280
|
+
product: bug.product,
|
|
281
|
+
status: bug.status,
|
|
282
|
+
pri: bug.pri,
|
|
283
|
+
severity: bug.severity,
|
|
284
|
+
assignedTo: bug.assignedTo,
|
|
285
|
+
openedBy: bug.openedBy,
|
|
286
|
+
resolvedBy: bug.resolvedBy,
|
|
287
|
+
openedDate: bug.openedDate,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return normalizeResult({
|
|
294
|
+
account: matchAccount,
|
|
295
|
+
scope: targetScope,
|
|
296
|
+
status: allowAllStatus ? "all" : Array.from(statusSet),
|
|
297
|
+
total: totalMatches,
|
|
298
|
+
products: rows,
|
|
299
|
+
bugs: includeDetails ? bugs : [],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function createClientFromCli({ argv, env }) {
|
|
305
|
+
const stored = loadConfig({ env }) || {};
|
|
306
|
+
const cliArgs = parseCliArgs(argv);
|
|
307
|
+
|
|
308
|
+
const baseUrl =
|
|
309
|
+
getOption(cliArgs, env, "ZENTAO_URL", "zentao-url") || stored.zentaoUrl || null;
|
|
310
|
+
const account =
|
|
311
|
+
getOption(cliArgs, env, "ZENTAO_ACCOUNT", "zentao-account") ||
|
|
312
|
+
stored.zentaoAccount ||
|
|
313
|
+
null;
|
|
314
|
+
const password =
|
|
315
|
+
getOption(cliArgs, env, "ZENTAO_PASSWORD", "zentao-password") ||
|
|
316
|
+
stored.zentaoPassword ||
|
|
317
|
+
null;
|
|
318
|
+
|
|
319
|
+
if (!baseUrl) throw new Error("Missing ZENTAO_URL or --zentao-url");
|
|
320
|
+
if (!account) throw new Error("Missing ZENTAO_ACCOUNT or --zentao-account");
|
|
321
|
+
if (!password) throw new Error("Missing ZENTAO_PASSWORD or --zentao-password");
|
|
322
|
+
|
|
323
|
+
return new ZentaoClient({ baseUrl, account, password });
|
|
324
|
+
}
|