@chenyingxian/zentao-mcp 0.1.0
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 +6 -0
- package/CONFIG.md +77 -0
- package/CONFIG.zh-CN.md +77 -0
- package/MCP_TOOLS.md +154 -0
- package/MCP_TOOLS.zh-CN.md +154 -0
- package/README.md +213 -0
- package/README.zh-CN.md +79 -0
- package/package.json +40 -0
- package/scripts/get-token.sh +86 -0
- package/src/index.js +271 -0
- package/src/zentao-client.js +1123 -0
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
5
|
+
const MAX_REC_PER_PAGE = 1000;
|
|
6
|
+
|
|
7
|
+
export class ZentaoApiError extends Error {
|
|
8
|
+
/**
|
|
9
|
+
* Create a normalized ZenTao API error.
|
|
10
|
+
* @param {string} message Error message.
|
|
11
|
+
* @param {{statusCode?: number, response?: unknown}} options Error options.
|
|
12
|
+
*/
|
|
13
|
+
constructor(message, options = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ZentaoApiError";
|
|
16
|
+
this.statusCode = options.statusCode;
|
|
17
|
+
this.response = options.response;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ZentaoClient {
|
|
22
|
+
/**
|
|
23
|
+
* Create a ZenTao RESTful API v1 client.
|
|
24
|
+
* @param {{baseUrl: string, account?: string, password?: string, token?: string, timeoutMs?: number, fetchImpl?: typeof fetch}} options Client options.
|
|
25
|
+
*/
|
|
26
|
+
constructor(options) {
|
|
27
|
+
if (!options?.baseUrl) throw new Error("ZENTAO_BASE_URL is required.");
|
|
28
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
29
|
+
this.account = options.account;
|
|
30
|
+
this.currentAccount = "";
|
|
31
|
+
this.password = options.password;
|
|
32
|
+
this.token = options.token || "";
|
|
33
|
+
this.timeoutMs = Number(options.timeoutMs || DEFAULT_TIMEOUT_MS);
|
|
34
|
+
this.fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
35
|
+
if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable. Please run with Node.js 18 or newer.");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fetch pending tasks assigned to the authenticated ZenTao user from "My Work".
|
|
39
|
+
* @param {{recPerPage?: number, pageId?: number}} params Query parameters.
|
|
40
|
+
* @returns {Promise<{account: string, title: string, todoCount: unknown, tasks: unknown[], raw: unknown}>} Normalized task result.
|
|
41
|
+
*/
|
|
42
|
+
async getMyTasks(params = {}) {
|
|
43
|
+
const recPerPage = clampRecPerPage(params?.recPerPage);
|
|
44
|
+
const pageId = assertPositiveId(params?.pageId ?? 1, "pageId");
|
|
45
|
+
const account = await this.getCurrentAccount();
|
|
46
|
+
const orderBy = sanitizeRouteSegment(params?.orderBy || "id_desc", "orderBy");
|
|
47
|
+
const route = `/my-work-task-assignedTo-${orderBy}-0-${pageId}-${recPerPage}.json`;
|
|
48
|
+
const response = await this.requestLegacyJson(route);
|
|
49
|
+
const tasks = Array.isArray(response?.tasks) ? response.tasks.filter((task) => sameAccount(getAssignedAccount(task), account)) : [];
|
|
50
|
+
return { account, title: String(response?.title || ""), todoCount: response?.todoCount, tasks, raw: response };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Fetch tasks finished by the authenticated ZenTao user.
|
|
54
|
+
* @param {{orderBy?: string, recPerPage?: number, pageId?: number}} params Query parameters.
|
|
55
|
+
* @returns {Promise<{account: string, title: string, count: number, tasks: unknown[], raw: unknown}>} Finished tasks.
|
|
56
|
+
*/
|
|
57
|
+
async getFinishedTasks(params = {}) {
|
|
58
|
+
const recPerPage = clampRecPerPage(params?.recPerPage);
|
|
59
|
+
const pageId = assertPositiveId(params?.pageId ?? 1, "pageId");
|
|
60
|
+
const account = await this.getCurrentAccount();
|
|
61
|
+
const response = await this.requestLegacyJson("/my-contribute-task-finishedBy.json");
|
|
62
|
+
const tasks = Array.isArray(response?.tasks) ? response.tasks.filter((task) => sameAccount(getFinishedAccount(task), account)) : [];
|
|
63
|
+
const sortedTasks = tasks.sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0));
|
|
64
|
+
const offset = (pageId - 1) * recPerPage;
|
|
65
|
+
return { account, title: String(response?.title || ""), count: sortedTasks.length, tasks: sortedTasks.slice(offset, offset + recPerPage), raw: response };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Fetch one ZenTao task by ID.
|
|
69
|
+
* @param {number|string} taskId Task ID.
|
|
70
|
+
* @returns {Promise<unknown>} Task detail.
|
|
71
|
+
*/
|
|
72
|
+
async getTask(taskId) {
|
|
73
|
+
const id = assertPositiveId(taskId, "taskId");
|
|
74
|
+
const task = await this.request("GET", `/tasks/${id}`);
|
|
75
|
+
if (task && typeof task === "object" && typeof task.desc === "string") {
|
|
76
|
+
return { ...task, descText: htmlToText(task.desc) };
|
|
77
|
+
}
|
|
78
|
+
return task;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read base data needed before submitting a task to test.
|
|
82
|
+
* @param {number|string} taskId Task ID.
|
|
83
|
+
* @returns {Promise<{taskId: number, storyId: number, projectId: number, productId: number, branch: number, task: unknown, story: unknown, product: unknown}>} Submit-test base data.
|
|
84
|
+
*/
|
|
85
|
+
async getSubmitTestBaseData(taskId) {
|
|
86
|
+
const id = assertPositiveId(taskId, "taskId");
|
|
87
|
+
const task = await this.getTask(id);
|
|
88
|
+
const storyId = Number(task?.storyID || task?.story || 0);
|
|
89
|
+
const projectId = Number(task?.project || 0);
|
|
90
|
+
if (!Number.isInteger(storyId) || storyId <= 0) throw new ZentaoApiError("Task does not contain a valid story ID.", { response: task });
|
|
91
|
+
if (!Number.isInteger(projectId) || projectId <= 0) throw new ZentaoApiError("Task does not contain a valid project ID.", { response: task });
|
|
92
|
+
const story = await this.request("GET", `/stories/${storyId}`);
|
|
93
|
+
const productId = Number(story?.product || task?.product || 0);
|
|
94
|
+
const branch = Number(story?.branch || 0);
|
|
95
|
+
if (!Number.isInteger(productId) || productId <= 0) throw new ZentaoApiError("Task story does not contain a valid product ID.", { response: story });
|
|
96
|
+
const product = await this.request("GET", `/products/${productId}`);
|
|
97
|
+
return { taskId: id, storyId, projectId, productId, branch, task, story, product };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Find test tasks corresponding to one development task.
|
|
101
|
+
* @param {number|string} taskId Development task ID.
|
|
102
|
+
* @returns {Promise<{taskId: number, storyId: number, projectId: number, productId: number, productName: string, branch: number, executionId: number, expectedTestTaskName: string, testTasks: unknown[], testUsers: unknown[], task: unknown}>} Matching test tasks.
|
|
103
|
+
*/
|
|
104
|
+
async findSubmitTestTasks(taskId) {
|
|
105
|
+
const baseData = await this.getSubmitTestBaseData(taskId);
|
|
106
|
+
const executionId = Number(baseData.task?.execution || 0);
|
|
107
|
+
if (!Number.isInteger(executionId) || executionId <= 0) throw new ZentaoApiError("Task does not contain a valid execution ID.", { response: baseData.task });
|
|
108
|
+
const expectedTestTaskName = buildExpectedTestTaskName(String(baseData.task?.name || ""));
|
|
109
|
+
const response = await this.request("GET", `/executions/${executionId}/tasks?limit=1000&page=1`);
|
|
110
|
+
const tasks = Array.isArray(response?.tasks) ? response.tasks : [];
|
|
111
|
+
const testTasks = tasks.filter((task) => Number(task?.story || task?.storyID || 0) === baseData.storyId && isMatchingTestTaskName(String(task?.name || ""), expectedTestTaskName));
|
|
112
|
+
return { taskId: baseData.taskId, storyId: baseData.storyId, projectId: baseData.projectId, productId: baseData.productId, productName: String(baseData.product?.name || ""), branch: baseData.branch, executionId, expectedTestTaskName, testTasks, testUsers: getAssignedUsers(testTasks), task: baseData.task };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Find builds created by current user for one development task.
|
|
116
|
+
* @param {number|string} taskId Development task ID.
|
|
117
|
+
* @returns {Promise<{taskId: number, storyId: number, projectId: number, executionId: number, expectedBuildName: string, builds: unknown[], task: unknown}>} Matching builds.
|
|
118
|
+
*/
|
|
119
|
+
async findSubmitTestBuilds(taskId) {
|
|
120
|
+
const baseData = await this.getSubmitTestBaseData(taskId);
|
|
121
|
+
const account = await this.getCurrentAccount();
|
|
122
|
+
const executionId = Number(baseData.task?.execution || 0);
|
|
123
|
+
if (!Number.isInteger(executionId) || executionId <= 0) throw new ZentaoApiError("Task does not contain a valid execution ID.", { response: baseData.task });
|
|
124
|
+
const expectedBuildName = buildExpectedBuildName(String(baseData.task?.name || ""));
|
|
125
|
+
const response = await this.request("GET", `/projects/${baseData.projectId}/builds?limit=100&page=1`);
|
|
126
|
+
const builds = Array.isArray(response?.builds) ? response.builds : [];
|
|
127
|
+
const matchedBuilds = builds.filter((build) => Number(build?.execution || 0) === executionId && sameAccount(String(build?.createdBy || build?.builder || ""), account) && isMatchingBuildName(String(build?.name || ""), expectedBuildName));
|
|
128
|
+
return { taskId: baseData.taskId, storyId: baseData.storyId, projectId: baseData.projectId, executionId, expectedBuildName, builds: matchedBuilds, task: baseData.task };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Prepare build creation payload without creating it.
|
|
132
|
+
* @param {number|string} taskId Development task ID.
|
|
133
|
+
* @returns {Promise<{taskId: number, storyId: number, projectId: number, executionId: number, payload: unknown, referenceBuild: unknown}>} Build creation data.
|
|
134
|
+
*/
|
|
135
|
+
async prepareSubmitTestBuildPayload(taskId) {
|
|
136
|
+
const baseData = await this.getSubmitTestBaseData(taskId);
|
|
137
|
+
const account = await this.getCurrentAccount();
|
|
138
|
+
const executionId = Number(baseData.task?.execution || 0);
|
|
139
|
+
if (!Number.isInteger(executionId) || executionId <= 0) throw new ZentaoApiError("Task does not contain a valid execution ID.", { response: baseData.task });
|
|
140
|
+
const response = await this.request("GET", `/executions/${executionId}/builds?limit=20&page=1`);
|
|
141
|
+
const builds = Array.isArray(response?.builds) ? response.builds : [];
|
|
142
|
+
const referenceBuild = findBestReferenceBuild(builds, account) || builds[0] || {};
|
|
143
|
+
const today = formatDate(new Date());
|
|
144
|
+
const payload = {
|
|
145
|
+
executionID: executionId,
|
|
146
|
+
product: baseData.productId,
|
|
147
|
+
productName: String(baseData.product?.name || ""),
|
|
148
|
+
branch: baseData.branch,
|
|
149
|
+
name: buildExpectedBuildName(String(baseData.task?.name || "")),
|
|
150
|
+
system: Number(referenceBuild.system || 0),
|
|
151
|
+
builder: account,
|
|
152
|
+
date: today,
|
|
153
|
+
scmPath: "",
|
|
154
|
+
filePath: "",
|
|
155
|
+
desc: "无",
|
|
156
|
+
stories: [baseData.storyId],
|
|
157
|
+
bugs: []
|
|
158
|
+
};
|
|
159
|
+
return { taskId: baseData.taskId, storyId: baseData.storyId, projectId: baseData.projectId, productId: baseData.productId, productName: String(baseData.product?.name || ""), branch: baseData.branch, executionId, payload, referenceBuild };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Create a ZenTao build for a development task and link the task story after creation.
|
|
163
|
+
* @param {number|string} taskId Development task ID.
|
|
164
|
+
* @param {{dryRun?: boolean}} params Creation options.
|
|
165
|
+
* @returns {Promise<{dryRun: boolean, taskId: number, storyId: number, projectId: number, productId: number, productName: string, branch: number, executionId: number, payload: unknown, createResult?: unknown, linkResult?: unknown, build?: unknown, buildId?: number, storyViewUrl?: string}>} Build creation result.
|
|
166
|
+
*/
|
|
167
|
+
async createBuildForTask(taskId, params = {}) {
|
|
168
|
+
const prepared = await this.prepareSubmitTestBuildPayload(taskId);
|
|
169
|
+
const testTaskInfo = await this.findSubmitTestTasks(taskId);
|
|
170
|
+
const dryRun = params.dryRun !== false;
|
|
171
|
+
if (dryRun) return { dryRun: true, taskId: prepared.taskId, storyId: prepared.storyId, projectId: prepared.projectId, productId: prepared.productId, productName: prepared.productName, branch: prepared.branch, executionId: prepared.executionId, expectedTestTaskName: testTaskInfo.expectedTestTaskName, testTasks: testTaskInfo.testTasks, testUsers: testTaskInfo.testUsers, payload: prepared.payload };
|
|
172
|
+
const createResult = await this.createBuildFromPayload(prepared.projectId, prepared.executionId, prepared.payload);
|
|
173
|
+
const buildId = getBuildIdFromCreateResult(createResult);
|
|
174
|
+
if (!buildId) throw new ZentaoApiError("ZenTao build creation succeeded but build ID was not found.", { response: createResult });
|
|
175
|
+
const linked = await this.linkBuildStory(buildId, prepared.storyId);
|
|
176
|
+
return { dryRun: false, taskId: prepared.taskId, storyId: prepared.storyId, projectId: prepared.projectId, productId: prepared.productId, productName: prepared.productName, branch: prepared.branch, executionId: prepared.executionId, expectedTestTaskName: testTaskInfo.expectedTestTaskName, testTasks: testTaskInfo.testTasks, testUsers: testTaskInfo.testUsers, payload: prepared.payload, createResult, linkResult: linked.result, build: linked.build, buildId, storyViewUrl: buildLegacyUrl(this.baseUrl, `/build-view-${buildId}-story-true.html`) };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Create a ZenTao test task for a development task and build.
|
|
180
|
+
* @param {number|string} taskId Development task ID.
|
|
181
|
+
* @param {number|string} buildId Build ID.
|
|
182
|
+
* @param {{dryRun?: boolean, owner?: string, begin?: string, end?: string, attachmentPaths?: string[]}} params Creation options.
|
|
183
|
+
* @returns {Promise<{dryRun: boolean, taskId: number, buildId: number, storyId: number, projectId: number, productId: number, productName: string, branch: number, executionId: number, owner: string, testUsers: unknown[], payload: unknown, createResult?: unknown, testtaskId?: number, testtaskViewUrl?: string}>} Test task creation result.
|
|
184
|
+
*/
|
|
185
|
+
async createTestTaskForTask(taskId, buildId, params = {}) {
|
|
186
|
+
const id = assertPositiveId(buildId, "buildId");
|
|
187
|
+
const baseData = await this.getSubmitTestBaseData(taskId);
|
|
188
|
+
const executionId = Number(baseData.task?.execution || 0);
|
|
189
|
+
if (!Number.isInteger(executionId) || executionId <= 0) throw new ZentaoApiError("Task does not contain a valid execution ID.", { response: baseData.task });
|
|
190
|
+
const build = await this.request("GET", `/builds/${id}`);
|
|
191
|
+
const testTaskInfo = await this.findSubmitTestTasks(taskId);
|
|
192
|
+
const owner = String(params.owner || testTaskInfo.testUsers[0]?.account || "");
|
|
193
|
+
if (!owner) throw new ZentaoApiError("No test owner found. Please pass owner explicitly.", { response: testTaskInfo });
|
|
194
|
+
const range = resolveTestTaskDateRange(params.begin, params.end);
|
|
195
|
+
const attachmentPaths = Array.isArray(params.attachmentPaths) ? params.attachmentPaths.filter(Boolean).map(String) : [];
|
|
196
|
+
const payload = {
|
|
197
|
+
product: baseData.productId,
|
|
198
|
+
execution: executionId,
|
|
199
|
+
build: id,
|
|
200
|
+
name: buildTestTaskName(String(build?.name || baseData.task?.name || "")),
|
|
201
|
+
owner,
|
|
202
|
+
members: [owner],
|
|
203
|
+
pri: Number(baseData.task?.pri || 3),
|
|
204
|
+
type: "system",
|
|
205
|
+
begin: range.begin,
|
|
206
|
+
end: range.end,
|
|
207
|
+
desc: buildTestTaskDesc(baseData),
|
|
208
|
+
attachmentPaths
|
|
209
|
+
};
|
|
210
|
+
const dryRun = params.dryRun !== false;
|
|
211
|
+
if (dryRun) return { dryRun: true, taskId: baseData.taskId, buildId: id, storyId: baseData.storyId, projectId: baseData.projectId, productId: baseData.productId, productName: String(baseData.product?.name || ""), branch: baseData.branch, executionId, owner, testUsers: testTaskInfo.testUsers, payload };
|
|
212
|
+
const createPayload = omitInternalPayloadFields(payload);
|
|
213
|
+
const createResult = attachmentPaths.length ? await this.requestMultipart("POST", `/projects/${baseData.projectId}/testtasks`, createPayload, attachmentPaths) : await this.request("POST", `/projects/${baseData.projectId}/testtasks`, createPayload);
|
|
214
|
+
const testtaskId = getTestTaskIdFromCreateResult(createResult);
|
|
215
|
+
if (!testtaskId) throw new ZentaoApiError("ZenTao test task creation succeeded but test task ID was not found.", { response: createResult });
|
|
216
|
+
return { dryRun: false, taskId: baseData.taskId, buildId: id, storyId: baseData.storyId, projectId: baseData.projectId, productId: baseData.productId, productName: String(baseData.product?.name || ""), branch: baseData.branch, executionId, owner, testUsers: testTaskInfo.testUsers, payload, createResult, testtaskId, testtaskViewUrl: buildLegacyUrl(this.baseUrl, `/testtask-cases-${testtaskId}.html`) };
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Prepare a publish task from development task IDs without creating it by default.
|
|
220
|
+
* @param {{taskIds: Array<number|string>, system: string, storyId?: number|string, releaseScope?: string, grayTag?: string, branch?: string, database?: string, env?: string, cron?: string, dependencies?: string, other?: string, risk?: string, releaseContent?: string[], dryRun?: boolean}} params Release task options.
|
|
221
|
+
* @returns {Promise<{dryRun: boolean, taskIds: number[], storyIds: number[], primaryStoryId: number, projectId: number, executionId: number, name: string, assignedTo: string, payload: unknown, warnings: string[]}>} Prepared release task data.
|
|
222
|
+
*/
|
|
223
|
+
async createReleaseTask(params) {
|
|
224
|
+
const taskIds = assertPositiveIdList(params?.taskIds, "taskIds");
|
|
225
|
+
const system = String(params?.system || "").trim();
|
|
226
|
+
if (!system) throw new Error("system is required.");
|
|
227
|
+
const releaseScope = String(params?.releaseScope || "生产环境").trim();
|
|
228
|
+
const currentAccount = await this.getCurrentAccount();
|
|
229
|
+
const baseItems = [];
|
|
230
|
+
for (const taskId of taskIds) baseItems.push(await this.getSubmitTestBaseData(taskId));
|
|
231
|
+
const mainItems = await this.getMainTaskBaseItems(baseItems);
|
|
232
|
+
const releaseUsers = await this.getReleaseTaskUsers(baseItems);
|
|
233
|
+
const primary = baseItems[0];
|
|
234
|
+
const branch = String(params?.branch || findFirstBranch(mainItems) || findFirstBranch(baseItems) || "").trim();
|
|
235
|
+
const warnings = [];
|
|
236
|
+
if (!branch) warnings.push("未从开发任务描述中解析到 git分支,请确认后补充 branch。");
|
|
237
|
+
if (isProductionScope(releaseScope) && !params?.grayTag) warnings.push("生产发布任务建议填写 grayTag,用于描述中记录灰度 tag。");
|
|
238
|
+
const storyIds = uniqueNumbers(baseItems.map((item) => item.storyId));
|
|
239
|
+
const primaryStoryId = params?.storyId ? assertPositiveId(params.storyId, "storyId") : storyIds[0];
|
|
240
|
+
const releaseContent = normalizeReleaseContent(params?.releaseContent, mainItems);
|
|
241
|
+
const releasePeople = formatReleasePeople(releaseUsers);
|
|
242
|
+
const payload = {
|
|
243
|
+
project: primary.projectId,
|
|
244
|
+
execution: Number(primary.task?.execution || 0),
|
|
245
|
+
story: primaryStoryId,
|
|
246
|
+
name: `发布-${system} xxxxxx ${releaseScope}`,
|
|
247
|
+
type: "publish",
|
|
248
|
+
assignedTo: currentAccount,
|
|
249
|
+
pri: 1,
|
|
250
|
+
estimate: 1,
|
|
251
|
+
estStarted: formatDate(new Date()),
|
|
252
|
+
deadline: formatDate(new Date()),
|
|
253
|
+
desc: buildReleaseTaskDesc({ system, releaseScope, branch, grayTag: params?.grayTag, releasePeople, releaseContent, database: params?.database, env: params?.env, cron: params?.cron, dependencies: params?.dependencies, other: params?.other, risk: params?.risk })
|
|
254
|
+
};
|
|
255
|
+
const dryRun = params?.dryRun !== false;
|
|
256
|
+
if (!dryRun) throw new ZentaoApiError("Release task creation is not enabled yet. Please confirm dry-run output first.", { response: payload });
|
|
257
|
+
return { dryRun: true, taskIds, mainTaskIds: uniqueNumbers(mainItems.map((item) => item.taskId)), storyIds, primaryStoryId, projectId: primary.projectId, executionId: Number(primary.task?.execution || 0), name: payload.name, assignedTo: currentAccount, releaseUsers: sortReleaseUsers(releaseUsers), payload, warnings };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Resolve parent development tasks for child tasks.
|
|
261
|
+
* @param {unknown[]} baseItems Task base data items.
|
|
262
|
+
* @returns {Promise<unknown[]>} Main task base data items.
|
|
263
|
+
*/
|
|
264
|
+
async getMainTaskBaseItems(baseItems) {
|
|
265
|
+
const result = [];
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
for (const item of baseItems) {
|
|
268
|
+
const parentId = Number(item?.task?.parent || 0);
|
|
269
|
+
const mainId = parentId > 0 ? parentId : item.taskId;
|
|
270
|
+
if (seen.has(mainId)) continue;
|
|
271
|
+
seen.add(mainId);
|
|
272
|
+
result.push(mainId === item.taskId ? item : await this.getSubmitTestBaseData(mainId));
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Find development and test assignees from main tasks and child tasks.
|
|
278
|
+
* @param {unknown[]} baseItems Task base data items.
|
|
279
|
+
* @returns {Promise<Array<{account: string, realname?: string, roles: string[]}>>} Release task users.
|
|
280
|
+
*/
|
|
281
|
+
async getReleaseTaskUsers(baseItems) {
|
|
282
|
+
const users = new Map();
|
|
283
|
+
for (const item of baseItems) {
|
|
284
|
+
const executionId = Number(item?.task?.execution || 0);
|
|
285
|
+
if (!executionId) continue;
|
|
286
|
+
const response = await this.request("GET", `/executions/${executionId}/tasks?limit=1000&page=1`);
|
|
287
|
+
const storyTasks = Array.isArray(response?.tasks) ? response.tasks.filter((task) => Number(task?.story || task?.storyID || 0) === item.storyId) : [];
|
|
288
|
+
addRoleUsers(users, findTaskTree(storyTasks, "devel"), "devel");
|
|
289
|
+
addRoleUsers(users, findTaskTree(storyTasks, "test"), "test");
|
|
290
|
+
}
|
|
291
|
+
return [...users.values()];
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Create a build using ZenTao's legacy form endpoint.
|
|
295
|
+
* @param {number} projectId Project ID.
|
|
296
|
+
* @param {number} executionId Execution ID.
|
|
297
|
+
* @param {unknown} payload Prepared build payload.
|
|
298
|
+
* @returns {Promise<unknown>} Creation response.
|
|
299
|
+
*/
|
|
300
|
+
async createBuildFromPayload(projectId, executionId, payload) {
|
|
301
|
+
const product = Number(payload?.product || 0);
|
|
302
|
+
if (!Number.isInteger(product) || product <= 0) throw new ZentaoApiError("Build payload does not contain a valid product ID.", { response: payload });
|
|
303
|
+
const body = new URLSearchParams();
|
|
304
|
+
body.append("execution", String(executionId));
|
|
305
|
+
body.append("product", String(product));
|
|
306
|
+
body.append("branch[]", String(Number(payload?.branch || 0)));
|
|
307
|
+
body.append("system", String(Number(payload?.system || 0)));
|
|
308
|
+
body.append("newSystem", "");
|
|
309
|
+
body.append("systemName", "");
|
|
310
|
+
body.append("name", String(payload?.name || ""));
|
|
311
|
+
body.append("builder", String(payload?.builder || ""));
|
|
312
|
+
body.append("date", String(payload?.date || formatDate(new Date())));
|
|
313
|
+
body.append("scmPath", String(payload?.scmPath || ""));
|
|
314
|
+
body.append("filePath", String(payload?.filePath || ""));
|
|
315
|
+
body.append("desc", String(payload?.desc || "无"));
|
|
316
|
+
body.append("project", String(projectId));
|
|
317
|
+
return this.requestLegacyForm(`/build-create-${executionId}.html?t=json`, body);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Link one story to an existing build through ZenTao's legacy form endpoint.
|
|
321
|
+
* @param {number|string} buildId Build ID.
|
|
322
|
+
* @param {number|string} storyId Story ID.
|
|
323
|
+
* @returns {Promise<{buildId: number, storyId: number, result: unknown, build: unknown}>} Link result and refreshed build detail.
|
|
324
|
+
*/
|
|
325
|
+
async linkBuildStory(buildId, storyId) {
|
|
326
|
+
const id = assertPositiveId(buildId, "buildId");
|
|
327
|
+
const story = assertPositiveId(storyId, "storyId");
|
|
328
|
+
const body = new URLSearchParams();
|
|
329
|
+
body.append("stories[]", String(story));
|
|
330
|
+
const result = await this.requestLegacyForm(`/build-linkStory-${id}--0.html?t=json`, body);
|
|
331
|
+
const build = await this.request("GET", `/builds/${id}`);
|
|
332
|
+
return { buildId: id, storyId: story, result, build };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Fetch effort logs from ZenTao "My Effort" and calendar overview.
|
|
336
|
+
* @param {{type?: string, orderBy?: string, recPerPage?: number, pageId?: number}} params Query parameters.
|
|
337
|
+
* @returns {Promise<{calendar: unknown, title: string, count: number, efforts: unknown[], raw: unknown}>} Effort logs.
|
|
338
|
+
*/
|
|
339
|
+
async getEffortLogs(params = {}) {
|
|
340
|
+
const type = sanitizeRouteSegment(params?.type || "all", "type");
|
|
341
|
+
const orderBy = sanitizeRouteSegment(params?.orderBy || "date_desc", "orderBy");
|
|
342
|
+
const recPerPage = clampRecPerPage(params?.recPerPage);
|
|
343
|
+
const pageId = assertPositiveId(params?.pageId ?? 1, "pageId");
|
|
344
|
+
const calendar = await this.requestLegacyJson("/effort-calendar.json");
|
|
345
|
+
const response = await this.requestLegacyJson(`/my-effort-${type}-${orderBy}-0-${recPerPage}-${pageId}.json`);
|
|
346
|
+
const efforts = Array.isArray(response?.efforts) ? response.efforts : [];
|
|
347
|
+
return { calendar: getCalendarSummary(calendar), title: String(response?.title || ""), count: efforts.length, efforts, raw: response };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Generate a weekly summary from effort logs.
|
|
351
|
+
* @param {{startDate?: string, endDate?: string, maxPages?: number, recPerPage?: number}} params Query parameters.
|
|
352
|
+
* @returns {Promise<{range: {startDate: string, endDate: string}, count: number, totalConsumed: number, summary: string, efforts: unknown[]}>} Weekly summary.
|
|
353
|
+
*/
|
|
354
|
+
async generateWeeklySummary(params = {}) {
|
|
355
|
+
const range = resolveWeekRange(params.startDate, params.endDate);
|
|
356
|
+
const recPerPage = clampRecPerPage(params.recPerPage || 100);
|
|
357
|
+
const maxPages = assertPositiveId(params.maxPages || 10, "maxPages");
|
|
358
|
+
const efforts = [];
|
|
359
|
+
for (let pageId = 1; pageId <= maxPages; pageId += 1) {
|
|
360
|
+
const page = await this.getEffortLogs({ recPerPage, pageId });
|
|
361
|
+
const pageEfforts = page.efforts || [];
|
|
362
|
+
efforts.push(...pageEfforts.filter((effort) => effort.date >= range.startDate && effort.date <= range.endDate && !isWeeklySummaryEffort(effort)));
|
|
363
|
+
if (pageEfforts.length === 0 || pageEfforts.some((effort) => effort.date < range.startDate)) break;
|
|
364
|
+
}
|
|
365
|
+
const sortedEfforts = efforts.sort((left, right) => right.date.localeCompare(left.date) || Number(right.id || 0) - Number(left.id || 0));
|
|
366
|
+
const totalConsumed = normalizeHour(sortedEfforts.reduce((sum, effort) => sum + Number(effort.consumed || 0), 0));
|
|
367
|
+
const summary = buildWeeklySummaryText(sortedEfforts, totalConsumed);
|
|
368
|
+
return { range, count: sortedEfforts.length, totalConsumed, summary, efforts: sortedEfforts };
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Get the authenticated account name.
|
|
372
|
+
* @returns {Promise<string>} Current account.
|
|
373
|
+
*/
|
|
374
|
+
async getCurrentAccount() {
|
|
375
|
+
const response = await this.request("GET", "/user");
|
|
376
|
+
const account = response?.profile?.account || this.account;
|
|
377
|
+
if (!account) throw new ZentaoApiError("ZenTao current user profile does not contain account.", { response });
|
|
378
|
+
this.currentAccount = String(account);
|
|
379
|
+
return this.currentAccount;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Ensure an API token is available.
|
|
383
|
+
* @returns {Promise<string>} API token.
|
|
384
|
+
*/
|
|
385
|
+
async ensureToken() {
|
|
386
|
+
if (this.token) return this.token;
|
|
387
|
+
if (!this.account || !this.password) throw new Error("ZENTAO_TOKEN or both ZENTAO_ACCOUNT and ZENTAO_PASSWORD are required.");
|
|
388
|
+
const response = await this.request("POST", "/tokens", { account: this.account, password: this.password }, false);
|
|
389
|
+
const token = getTokenFromResponse(response);
|
|
390
|
+
if (!token) throw new ZentaoApiError("ZenTao login failed or token is empty.", { response });
|
|
391
|
+
this.token = token;
|
|
392
|
+
return this.token;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Execute one ZenTao API request.
|
|
396
|
+
* @param {"GET"|"POST"|"PUT"|"DELETE"} method HTTP method.
|
|
397
|
+
* @param {string} path API path beginning with slash.
|
|
398
|
+
* @param {unknown} body JSON body.
|
|
399
|
+
* @param {boolean} authenticated Whether token auth is required.
|
|
400
|
+
* @returns {Promise<unknown>} JSON response.
|
|
401
|
+
*/
|
|
402
|
+
async request(method, path, body = undefined, authenticated = true) {
|
|
403
|
+
const headers = { Accept: "application/json" };
|
|
404
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
405
|
+
if (authenticated) headers.Token = await this.ensureToken();
|
|
406
|
+
const response = await fetchWithTimeout(this.fetchImpl, buildApiUrl(this.baseUrl, path), { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }, this.timeoutMs);
|
|
407
|
+
const payload = await readJsonResponse(response);
|
|
408
|
+
if (!response.ok) throw new ZentaoApiError(`ZenTao API request failed with HTTP ${response.status}.`, { statusCode: response.status, response: payload });
|
|
409
|
+
if (payload?.status === "fail") throw new ZentaoApiError("ZenTao API returned fail status.", { statusCode: response.status, response: payload });
|
|
410
|
+
return payload;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Execute one ZenTao API multipart request.
|
|
414
|
+
* @param {"POST"|"PUT"} method HTTP method.
|
|
415
|
+
* @param {string} path API path beginning with slash.
|
|
416
|
+
* @param {Record<string, unknown>} fields Form fields.
|
|
417
|
+
* @param {string[]} filePaths Local file paths to upload as files[].
|
|
418
|
+
* @returns {Promise<unknown>} JSON response.
|
|
419
|
+
*/
|
|
420
|
+
async requestMultipart(method, path, fields, filePaths) {
|
|
421
|
+
const form = new FormData();
|
|
422
|
+
appendFormFields(form, fields);
|
|
423
|
+
await appendFiles(form, filePaths);
|
|
424
|
+
const headers = { Accept: "application/json", Token: await this.ensureToken() };
|
|
425
|
+
const response = await fetchWithTimeout(this.fetchImpl, buildApiUrl(this.baseUrl, path), { method, headers, body: form }, this.timeoutMs);
|
|
426
|
+
const payload = await readJsonResponse(response);
|
|
427
|
+
if (!response.ok) throw new ZentaoApiError(`ZenTao API multipart request failed with HTTP ${response.status}.`, { statusCode: response.status, response: payload });
|
|
428
|
+
if (payload?.status === "fail") throw new ZentaoApiError("ZenTao API returned fail status.", { statusCode: response.status, response: payload });
|
|
429
|
+
return payload;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Execute one ZenTao legacy JSON route request.
|
|
433
|
+
* @param {string} route Route path beginning with slash.
|
|
434
|
+
* @returns {Promise<unknown>} Parsed legacy JSON data payload.
|
|
435
|
+
*/
|
|
436
|
+
async requestLegacyJson(route) {
|
|
437
|
+
const headers = { Accept: "application/json", Token: await this.ensureToken() };
|
|
438
|
+
const response = await fetchWithTimeout(this.fetchImpl, buildLegacyUrl(this.baseUrl, route), { method: "GET", headers }, this.timeoutMs);
|
|
439
|
+
const payload = await readJsonResponse(response);
|
|
440
|
+
if (!response.ok) throw new ZentaoApiError(`ZenTao legacy request failed with HTTP ${response.status}.`, { statusCode: response.status, response: payload });
|
|
441
|
+
if (payload?.status !== "success") throw new ZentaoApiError("ZenTao legacy API returned non-success status.", { statusCode: response.status, response: payload });
|
|
442
|
+
if (typeof payload.data !== "string") return payload;
|
|
443
|
+
try {
|
|
444
|
+
return JSON.parse(payload.data);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
throw new ZentaoApiError("ZenTao legacy API returned malformed data JSON.", { statusCode: response.status, response: payload });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Submit one ZenTao legacy form route and parse its JSON response.
|
|
451
|
+
* @param {string} route Legacy route.
|
|
452
|
+
* @param {URLSearchParams} body Form body.
|
|
453
|
+
* @returns {Promise<unknown>} Parsed submit response.
|
|
454
|
+
*/
|
|
455
|
+
async requestLegacyForm(route, body) {
|
|
456
|
+
const headers = { Accept: "application/json, text/javascript, */*; q=0.01", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", Token: await this.ensureToken() };
|
|
457
|
+
const response = await fetchWithTimeout(this.fetchImpl, buildLegacyUrl(this.baseUrl, route), { method: "POST", headers, body }, this.timeoutMs);
|
|
458
|
+
const payload = await readJsonResponse(response);
|
|
459
|
+
if (!response.ok) throw new ZentaoApiError(`ZenTao legacy form request failed with HTTP ${response.status}.`, { statusCode: response.status, response: payload });
|
|
460
|
+
if (payload?.result && payload.result !== "success") throw new ZentaoApiError("ZenTao legacy form returned non-success result.", { statusCode: response.status, response: payload });
|
|
461
|
+
return payload;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Build a client from process environment variables.
|
|
467
|
+
* @param {NodeJS.ProcessEnv} env Process environment.
|
|
468
|
+
* @returns {ZentaoClient} ZenTao client.
|
|
469
|
+
*/
|
|
470
|
+
export function createClientFromEnv(env = process.env) {
|
|
471
|
+
return new ZentaoClient({ baseUrl: env.ZENTAO_BASE_URL, account: env.ZENTAO_ACCOUNT, password: env.ZENTAO_PASSWORD, token: env.ZENTAO_TOKEN, timeoutMs: env.ZENTAO_TIMEOUT_MS });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Normalize a ZenTao site URL.
|
|
476
|
+
* @param {string} value Raw URL.
|
|
477
|
+
* @returns {string} URL without trailing slash.
|
|
478
|
+
*/
|
|
479
|
+
function normalizeBaseUrl(value) {
|
|
480
|
+
return String(value).trim().replace(/\/+$/, "");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Build a RESTful API v1 URL from a site root or api.php URL.
|
|
485
|
+
* @param {string} baseUrl Base URL.
|
|
486
|
+
* @param {string} path API path.
|
|
487
|
+
* @returns {string} Full request URL.
|
|
488
|
+
*/
|
|
489
|
+
function buildApiUrl(baseUrl, path) {
|
|
490
|
+
const apiRoot = "/api.php/v1";
|
|
491
|
+
if (baseUrl.endsWith(apiRoot)) return `${baseUrl}${path}`;
|
|
492
|
+
if (baseUrl.endsWith("/api.php")) return `${baseUrl}/v1${path}`;
|
|
493
|
+
if (baseUrl.endsWith("/api.php/v2")) return `${baseUrl.replace(/\/api\.php\/v2$/, apiRoot)}${path}`;
|
|
494
|
+
return `${baseUrl}${apiRoot}${path}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Build a legacy ZenTao JSON route URL from a site root.
|
|
499
|
+
* @param {string} baseUrl Base URL.
|
|
500
|
+
* @param {string} route Legacy route.
|
|
501
|
+
* @returns {string} Full request URL.
|
|
502
|
+
*/
|
|
503
|
+
function buildLegacyUrl(baseUrl, route) {
|
|
504
|
+
const siteRoot = baseUrl.replace(/\/api\.php(\/v[12])?$/, "");
|
|
505
|
+
return `${siteRoot}${route}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Restrict dynamic legacy route segments to simple safe characters.
|
|
510
|
+
* @param {string} value Raw route segment.
|
|
511
|
+
* @param {string} name Field name.
|
|
512
|
+
* @returns {string} Safe route segment.
|
|
513
|
+
*/
|
|
514
|
+
function sanitizeRouteSegment(value, name) {
|
|
515
|
+
const segment = String(value);
|
|
516
|
+
if (!/^[A-Za-z0-9_]+$/.test(segment)) throw new Error(`${name} contains unsupported characters.`);
|
|
517
|
+
return segment;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Extract a token from known ZenTao login response shapes.
|
|
522
|
+
* @param {unknown} response Login response.
|
|
523
|
+
* @returns {string} API token.
|
|
524
|
+
*/
|
|
525
|
+
function getTokenFromResponse(response) {
|
|
526
|
+
const token = response?.token || response?.data?.token || response?.data?.user?.token;
|
|
527
|
+
return token ? String(token) : "";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Extract a build ID from ZenTao create responses.
|
|
532
|
+
* @param {unknown} response Build creation response.
|
|
533
|
+
* @returns {number} Build ID, or 0 when unavailable.
|
|
534
|
+
*/
|
|
535
|
+
function getBuildIdFromCreateResult(response) {
|
|
536
|
+
const directId = Number(response?.id || response?.buildID || response?.data?.id || response?.data?.buildID || 0);
|
|
537
|
+
if (Number.isInteger(directId) && directId > 0) return directId;
|
|
538
|
+
const load = String(response?.load || response?.locate || "");
|
|
539
|
+
const match = load.match(/build-view-(\d+)/);
|
|
540
|
+
return match ? Number(match[1]) : 0;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Extract a test task ID from ZenTao create responses.
|
|
545
|
+
* @param {unknown} response Test task creation response.
|
|
546
|
+
* @returns {number} Test task ID, or 0 when unavailable.
|
|
547
|
+
*/
|
|
548
|
+
function getTestTaskIdFromCreateResult(response) {
|
|
549
|
+
const directId = Number(response?.id || response?.testtaskID || response?.taskID || response?.data?.id || response?.data?.testtaskID || 0);
|
|
550
|
+
if (Number.isInteger(directId) && directId > 0) return directId;
|
|
551
|
+
const load = String(response?.load || response?.locate || "");
|
|
552
|
+
const match = load.match(/testtask-(?:cases|view)-(\d+)/);
|
|
553
|
+
return match ? Number(match[1]) : 0;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Remove local-only fields before submitting to ZenTao.
|
|
558
|
+
* @param {Record<string, unknown>} payload Tool payload.
|
|
559
|
+
* @returns {Record<string, unknown>} ZenTao payload.
|
|
560
|
+
*/
|
|
561
|
+
function omitInternalPayloadFields(payload) {
|
|
562
|
+
const { attachmentPaths, ...fields } = payload;
|
|
563
|
+
return fields;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Append scalar and array fields to a multipart form.
|
|
568
|
+
* @param {FormData} form Multipart form.
|
|
569
|
+
* @param {Record<string, unknown>} fields Form fields.
|
|
570
|
+
* @returns {void}
|
|
571
|
+
*/
|
|
572
|
+
function appendFormFields(form, fields) {
|
|
573
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
574
|
+
if (value === undefined || value === null) continue;
|
|
575
|
+
if (Array.isArray(value)) {
|
|
576
|
+
for (const item of value) form.append(`${name}[]`, String(item));
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
form.append(name, String(value));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Append local files to a multipart form as ZenTao files[] uploads.
|
|
585
|
+
* @param {FormData} form Multipart form.
|
|
586
|
+
* @param {string[]} filePaths Local file paths.
|
|
587
|
+
* @returns {Promise<void>} Resolves after files are read.
|
|
588
|
+
*/
|
|
589
|
+
async function appendFiles(form, filePaths) {
|
|
590
|
+
for (const filePath of filePaths) {
|
|
591
|
+
const buffer = await readFile(filePath);
|
|
592
|
+
form.append("files[]", new Blob([buffer]), basename(filePath));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Read the assigned account from V1 task payloads.
|
|
598
|
+
* @param {unknown} task ZenTao task.
|
|
599
|
+
* @returns {string} Assigned account.
|
|
600
|
+
*/
|
|
601
|
+
function getAssignedAccount(task) {
|
|
602
|
+
const assignedTo = task?.assignedTo;
|
|
603
|
+
if (typeof assignedTo === "string") return assignedTo;
|
|
604
|
+
if (assignedTo?.account) return String(assignedTo.account);
|
|
605
|
+
return "";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Read unique assignees from matched test tasks.
|
|
610
|
+
* @param {unknown[]} tasks Test tasks.
|
|
611
|
+
* @returns {Array<{account: string, realname?: string}>} Assigned users.
|
|
612
|
+
*/
|
|
613
|
+
function getAssignedUsers(tasks) {
|
|
614
|
+
const users = new Map();
|
|
615
|
+
for (const task of tasks) {
|
|
616
|
+
const assignedTo = task?.assignedTo;
|
|
617
|
+
const account = typeof assignedTo === "string" ? assignedTo : String(assignedTo?.account || "");
|
|
618
|
+
if (!account || users.has(account)) continue;
|
|
619
|
+
const user = { account };
|
|
620
|
+
if (typeof assignedTo === "object" && assignedTo?.realname) user.realname = String(assignedTo.realname);
|
|
621
|
+
users.set(account, user);
|
|
622
|
+
}
|
|
623
|
+
return [...users.values()];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Find main tasks and child tasks for a role under one story.
|
|
628
|
+
* @param {unknown[]} tasks Same-story tasks.
|
|
629
|
+
* @param {string} type Task type.
|
|
630
|
+
* @returns {unknown[]} Related tasks.
|
|
631
|
+
*/
|
|
632
|
+
function findTaskTree(tasks, type) {
|
|
633
|
+
const mainIds = tasks.filter((task) => task?.type === type && Number(task?.parent || 0) === 0).map((task) => Number(task.id));
|
|
634
|
+
return tasks.filter((task) => task?.type === type && (mainIds.includes(Number(task.id)) || mainIds.includes(Number(task.parent || 0))));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Add assigned users from task list to a role-aware map.
|
|
639
|
+
* @param {Map<string, {account: string, realname?: string, roles: string[]}>} users User map.
|
|
640
|
+
* @param {unknown[]} tasks Tasks.
|
|
641
|
+
* @param {string} role User role.
|
|
642
|
+
* @returns {void}
|
|
643
|
+
*/
|
|
644
|
+
function addRoleUsers(users, tasks, role) {
|
|
645
|
+
for (const task of tasks) {
|
|
646
|
+
const taskUser = getTaskContributor(task);
|
|
647
|
+
const account = taskUser.account;
|
|
648
|
+
if (!account) continue;
|
|
649
|
+
const openedAccount = getUserObject(task?.openedBy)?.account || "";
|
|
650
|
+
if (openedAccount && sameAccount(account, openedAccount)) continue;
|
|
651
|
+
const current = users.get(account) || { account, roles: [] };
|
|
652
|
+
if (taskUser.realname) current.realname = taskUser.realname;
|
|
653
|
+
if (!current.roles.includes(role)) current.roles.push(role);
|
|
654
|
+
users.set(account, current);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Pick the real contributor for a task.
|
|
660
|
+
* @param {unknown} task ZenTao task.
|
|
661
|
+
* @returns {{account: string, realname?: string}} Contributor.
|
|
662
|
+
*/
|
|
663
|
+
function getTaskContributor(task) {
|
|
664
|
+
return getUserObject(task?.finishedBy) || getUserObject(task?.assignedTo) || { account: "" };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Normalize a ZenTao user field.
|
|
669
|
+
* @param {unknown} value User field.
|
|
670
|
+
* @returns {{account: string, realname?: string}|null} User object.
|
|
671
|
+
*/
|
|
672
|
+
function getUserObject(value) {
|
|
673
|
+
if (!value) return null;
|
|
674
|
+
if (typeof value === "string") return value ? { account: value } : null;
|
|
675
|
+
const account = String(value?.account || "");
|
|
676
|
+
if (!account) return null;
|
|
677
|
+
const user = { account };
|
|
678
|
+
if (value?.realname) user.realname = String(value.realname);
|
|
679
|
+
return user;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Format release participants with real names when available.
|
|
684
|
+
* @param {Array<{account: string, realname?: string}>} users Release users.
|
|
685
|
+
* @returns {string} Participant text.
|
|
686
|
+
*/
|
|
687
|
+
function formatReleasePeople(users) {
|
|
688
|
+
const sortedUsers = sortReleaseUsers(users);
|
|
689
|
+
return sortedUsers.map((user) => user.realname || user.account).filter(Boolean).join("、");
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Sort release users by developer list first, then tester list, each lexically.
|
|
694
|
+
* @param {Array<{account: string, realname?: string, roles: string[]}>} users Release users.
|
|
695
|
+
* @returns {Array<{account: string, realname?: string, roles: string[]}>} Sorted users.
|
|
696
|
+
*/
|
|
697
|
+
function sortReleaseUsers(users) {
|
|
698
|
+
const emitted = new Set();
|
|
699
|
+
const result = [];
|
|
700
|
+
for (const role of ["devel", "test"]) {
|
|
701
|
+
const roleUsers = users.filter((user) => user.roles.includes(role) && !emitted.has(user.account)).sort(compareReleaseUser);
|
|
702
|
+
for (const user of roleUsers) {
|
|
703
|
+
emitted.add(user.account);
|
|
704
|
+
result.push(user);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return result;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Compare release users by display name.
|
|
712
|
+
* @param {{account: string, realname?: string}} left Left user.
|
|
713
|
+
* @param {{account: string, realname?: string}} right Right user.
|
|
714
|
+
* @returns {number} Sort comparison.
|
|
715
|
+
*/
|
|
716
|
+
function compareReleaseUser(left, right) {
|
|
717
|
+
return (left.realname || left.account).localeCompare(right.realname || right.account, "zh-Hans-CN");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Read the finished account from task payloads.
|
|
722
|
+
* @param {unknown} task ZenTao task.
|
|
723
|
+
* @returns {string} Finished account.
|
|
724
|
+
*/
|
|
725
|
+
function getFinishedAccount(task) {
|
|
726
|
+
const finishedBy = task?.finishedBy;
|
|
727
|
+
if (typeof finishedBy === "string") return finishedBy;
|
|
728
|
+
if (finishedBy?.account) return String(finishedBy.account);
|
|
729
|
+
return "";
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Compare two date strings in descending order.
|
|
734
|
+
* @param {string|undefined} left First date.
|
|
735
|
+
* @param {string|undefined} right Second date.
|
|
736
|
+
* @returns {number} Sort comparison.
|
|
737
|
+
*/
|
|
738
|
+
function compareDateDesc(left, right) {
|
|
739
|
+
return String(right || "").localeCompare(String(left || ""));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Compare ZenTao accounts without being tripped by letter case.
|
|
744
|
+
* @param {string} left First account.
|
|
745
|
+
* @param {string} right Second account.
|
|
746
|
+
* @returns {boolean} Whether both accounts match.
|
|
747
|
+
*/
|
|
748
|
+
function sameAccount(left, right) {
|
|
749
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Convert ZenTao HTML content into readable plain text.
|
|
754
|
+
* @param {string} html HTML content.
|
|
755
|
+
* @returns {string} Plain text content.
|
|
756
|
+
*/
|
|
757
|
+
function htmlToText(html) {
|
|
758
|
+
return html
|
|
759
|
+
.replace(/<br\s*\/?>(?=.)/gi, "\n")
|
|
760
|
+
.replace(/<\/p>/gi, "\n")
|
|
761
|
+
.replace(/<[^>]*>/g, "")
|
|
762
|
+
.replace(/ /g, " ")
|
|
763
|
+
.replace(/"/g, "\"")
|
|
764
|
+
.replace(/'/g, "'")
|
|
765
|
+
.replace(/</g, "<")
|
|
766
|
+
.replace(/>/g, ">")
|
|
767
|
+
.replace(/&/g, "&")
|
|
768
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
769
|
+
.trim();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Build a safe calendar summary without leaking user internals.
|
|
774
|
+
* @param {unknown} calendar Raw calendar data.
|
|
775
|
+
* @returns {unknown} Calendar summary.
|
|
776
|
+
*/
|
|
777
|
+
function getCalendarSummary(calendar) {
|
|
778
|
+
return {
|
|
779
|
+
title: calendar?.title || "",
|
|
780
|
+
date: calendar?.date || "",
|
|
781
|
+
effortCount: calendar?.effortCount,
|
|
782
|
+
todoCount: calendar?.todoCount,
|
|
783
|
+
user: calendar?.user ? { account: calendar.user.account, realname: calendar.user.realname } : null
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Resolve a week date range.
|
|
789
|
+
* @param {string|undefined} startDate Start date.
|
|
790
|
+
* @param {string|undefined} endDate End date.
|
|
791
|
+
* @returns {{startDate: string, endDate: string}} Date range.
|
|
792
|
+
*/
|
|
793
|
+
function resolveWeekRange(startDate, endDate) {
|
|
794
|
+
if (startDate && endDate) return { startDate, endDate };
|
|
795
|
+
const now = new Date();
|
|
796
|
+
const day = now.getDay() || 7;
|
|
797
|
+
const start = new Date(now);
|
|
798
|
+
start.setDate(now.getDate() - day + 1);
|
|
799
|
+
const end = new Date(start);
|
|
800
|
+
end.setDate(start.getDate() + 6);
|
|
801
|
+
return { startDate: formatDate(start), endDate: formatDate(end) };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Format a Date as yyyy-mm-dd.
|
|
806
|
+
* @param {Date} date Date value.
|
|
807
|
+
* @returns {string} Formatted date.
|
|
808
|
+
*/
|
|
809
|
+
function formatDate(date) {
|
|
810
|
+
const year = date.getFullYear();
|
|
811
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
812
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
813
|
+
return `${year}-${month}-${day}`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Resolve the default test task date range.
|
|
818
|
+
* @param {string|undefined} begin Begin date.
|
|
819
|
+
* @param {string|undefined} end End date.
|
|
820
|
+
* @returns {{begin: string, end: string}} Date range.
|
|
821
|
+
*/
|
|
822
|
+
function resolveTestTaskDateRange(begin, end) {
|
|
823
|
+
if (begin && end) return { begin, end };
|
|
824
|
+
const start = new Date();
|
|
825
|
+
const finish = new Date(start);
|
|
826
|
+
finish.setDate(start.getDate() + 4);
|
|
827
|
+
return { begin: begin || formatDate(start), end: end || formatDate(finish) };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Detect the existing weekly-summary effort entry.
|
|
832
|
+
* @param {unknown} effort Effort record.
|
|
833
|
+
* @returns {boolean} Whether this effort is a weekly summary.
|
|
834
|
+
*/
|
|
835
|
+
function isWeeklySummaryEffort(effort) {
|
|
836
|
+
const work = String(effort?.work || "");
|
|
837
|
+
return Number(effort?.objectID || 0) === 0 || /^1、/.test(work) && /共计[\d.]+小时/.test(work);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Build weekly summary text in the established numbered format.
|
|
842
|
+
* @param {unknown[]} efforts Effort records.
|
|
843
|
+
* @param {number} totalConsumed Total consumed hours.
|
|
844
|
+
* @returns {string} Weekly summary text.
|
|
845
|
+
*/
|
|
846
|
+
function buildWeeklySummaryText(efforts, totalConsumed) {
|
|
847
|
+
const items = efforts.map((effort) => cleanWorkText(String(effort?.work || ""))).filter(Boolean);
|
|
848
|
+
const uniqueItems = [...new Set(items)];
|
|
849
|
+
const body = uniqueItems.map((item, index) => `${index + 1}、${item}。`).join("");
|
|
850
|
+
return `${body}共计${formatHour(totalConsumed)}小时。`;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Clean one effort work item for weekly summary.
|
|
855
|
+
* @param {string} value Raw work text.
|
|
856
|
+
* @returns {string} Clean work text.
|
|
857
|
+
*/
|
|
858
|
+
function cleanWorkText(value) {
|
|
859
|
+
return value
|
|
860
|
+
.replace(/^【AI】/, "")
|
|
861
|
+
.replace(/[。.\s]+$/g, "")
|
|
862
|
+
.trim();
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Normalize hour precision.
|
|
867
|
+
* @param {number} value Raw hours.
|
|
868
|
+
* @returns {number} Normalized hours.
|
|
869
|
+
*/
|
|
870
|
+
function normalizeHour(value) {
|
|
871
|
+
return Math.round(value * 10) / 10;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Format hour without unnecessary trailing decimal zero.
|
|
876
|
+
* @param {number} value Hours.
|
|
877
|
+
* @returns {string} Formatted hours.
|
|
878
|
+
*/
|
|
879
|
+
function formatHour(value) {
|
|
880
|
+
return Number.isInteger(value) ? String(value) : String(value);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Build the expected test task name from a development task name.
|
|
885
|
+
* @param {string} taskName Development task name.
|
|
886
|
+
* @returns {string} Expected test task name.
|
|
887
|
+
*/
|
|
888
|
+
function buildExpectedTestTaskName(taskName) {
|
|
889
|
+
if (taskName.startsWith("开发-")) return `测试-${taskName.slice("开发-".length)}`;
|
|
890
|
+
return taskName.replace(/^开发/, "测试");
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Check whether a candidate task name matches the expected test task.
|
|
895
|
+
* @param {string} candidate Candidate task name.
|
|
896
|
+
* @param {string} expected Expected task name.
|
|
897
|
+
* @returns {boolean} Whether names match.
|
|
898
|
+
*/
|
|
899
|
+
function isMatchingTestTaskName(candidate, expected) {
|
|
900
|
+
return candidate === expected || stripTrailingNumber(candidate) === stripTrailingNumber(expected);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Strip a trailing ASCII number suffix.
|
|
905
|
+
* @param {string} value Raw task name.
|
|
906
|
+
* @returns {string} Task name without trailing number.
|
|
907
|
+
*/
|
|
908
|
+
function stripTrailingNumber(value) {
|
|
909
|
+
return value.replace(/\d+$/, "");
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Build the expected build name from a development task name.
|
|
914
|
+
* @param {string} taskName Development task name.
|
|
915
|
+
* @returns {string} Expected build name.
|
|
916
|
+
*/
|
|
917
|
+
function buildExpectedBuildName(taskName) {
|
|
918
|
+
return stripTrailingNumber(taskName.replace(/^开发-/, "").replace(/^开发/, "")).trim();
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Build the test task name from a build name.
|
|
923
|
+
* @param {string} buildName Build name.
|
|
924
|
+
* @returns {string} Test task name.
|
|
925
|
+
*/
|
|
926
|
+
function buildTestTaskName(buildName) {
|
|
927
|
+
const cleanName = stripTrailingNumber(buildName.replace(/^开发-/, "").replace(/^开发/, "").replace(/^测试-/, "")).trim();
|
|
928
|
+
return `测试-${cleanName}`;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Build the test task description from related task and story data.
|
|
933
|
+
* @param {unknown} baseData Submit-test base data.
|
|
934
|
+
* @returns {string} HTML description.
|
|
935
|
+
*/
|
|
936
|
+
function buildTestTaskDesc(baseData) {
|
|
937
|
+
const title = String(baseData?.story?.title || baseData?.task?.storyTitle || "");
|
|
938
|
+
return title ? `<p>关联需求:${escapeHtml(title)}</p>` : "<p>无</p>";
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Escape a short text value for HTML.
|
|
943
|
+
* @param {string} value Raw text.
|
|
944
|
+
* @returns {string} Escaped HTML text.
|
|
945
|
+
*/
|
|
946
|
+
function escapeHtml(value) {
|
|
947
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Find the first git branch written in related development task descriptions.
|
|
952
|
+
* @param {unknown[]} baseItems Task base data items.
|
|
953
|
+
* @returns {string} Parsed branch.
|
|
954
|
+
*/
|
|
955
|
+
function findFirstBranch(baseItems) {
|
|
956
|
+
for (const item of baseItems) {
|
|
957
|
+
const text = htmlToText(String(item?.task?.desc || ""));
|
|
958
|
+
const match = text.match(/git\s*分支\s*[::]\s*([^\s\n\r]+)/i);
|
|
959
|
+
if (match?.[1]) return match[1].trim();
|
|
960
|
+
}
|
|
961
|
+
return "";
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Whether the release scope is production-like.
|
|
966
|
+
* @param {string} scope Release scope.
|
|
967
|
+
* @returns {boolean} Whether production.
|
|
968
|
+
*/
|
|
969
|
+
function isProductionScope(scope) {
|
|
970
|
+
return /生产/.test(scope) && !/灰度/.test(scope);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Normalize release content lines.
|
|
975
|
+
* @param {string[]|undefined} releaseContent Explicit release content.
|
|
976
|
+
* @param {unknown[]} baseItems Task base data items.
|
|
977
|
+
* @returns {string[]} Release content lines.
|
|
978
|
+
*/
|
|
979
|
+
function normalizeReleaseContent(releaseContent, baseItems) {
|
|
980
|
+
if (Array.isArray(releaseContent) && releaseContent.some(Boolean)) return releaseContent.map(String).map((item) => item.trim()).filter(Boolean);
|
|
981
|
+
return baseItems.map((item) => String(item?.story?.title || item?.task?.storyTitle || item?.task?.name || "")).map((item) => item.replace(/^开发-/, "").replace(/^测试-/, "").trim()).filter(Boolean);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Build the release task description in the established nine-section format.
|
|
986
|
+
* @param {{system: string, releaseScope: string, branch: string, grayTag?: string, releasePeople: string, releaseContent: string[], database?: string, env?: string, cron?: string, dependencies?: string, other?: string, risk?: string}} params Release content.
|
|
987
|
+
* @returns {string} HTML description.
|
|
988
|
+
*/
|
|
989
|
+
function buildReleaseTaskDesc(params) {
|
|
990
|
+
const codeLines = [params.system, params.branch ? `git分支: ${params.branch}` : "git分支: ", "tag: xxxxxx"];
|
|
991
|
+
if (isProductionScope(params.releaseScope) && params.grayTag) codeLines.push(`灰度tag: ${params.grayTag}`);
|
|
992
|
+
const releaseLines = [];
|
|
993
|
+
if (params.releasePeople) releaseLines.push(params.releasePeople);
|
|
994
|
+
releaseLines.push(...(params.releaseContent.length ? params.releaseContent.map((item, index) => `${index + 1}.${item}`) : ["1."]));
|
|
995
|
+
return [
|
|
996
|
+
section("一.更新范围", params.releaseScope),
|
|
997
|
+
section("二.代码", codeLines.join("\n")),
|
|
998
|
+
section("三.数据库", defaultNone(params.database)),
|
|
999
|
+
section("四.环境变量", defaultNone(params.env)),
|
|
1000
|
+
section("五.计划任务", defaultNone(params.cron)),
|
|
1001
|
+
section("六.依赖包", defaultNone(params.dependencies)),
|
|
1002
|
+
section("七.其它", defaultNone(params.other)),
|
|
1003
|
+
section("八.对外说明发布的内容", releaseLines.join("\n")),
|
|
1004
|
+
section("九.风险", defaultNone(params.risk))
|
|
1005
|
+
].join("");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Build one HTML section.
|
|
1010
|
+
* @param {string} title Section title.
|
|
1011
|
+
* @param {string} body Section body.
|
|
1012
|
+
* @returns {string} HTML section.
|
|
1013
|
+
*/
|
|
1014
|
+
function section(title, body) {
|
|
1015
|
+
const lines = String(body || "").split(/\r?\n/).map((line) => `<p>${escapeHtml(line)}</p>`).join("");
|
|
1016
|
+
return `<p>${escapeHtml(title)}</p>${lines}${"<p></p>".repeat(6)}`;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Default empty section text to "无".
|
|
1021
|
+
* @param {string|undefined} value Raw value.
|
|
1022
|
+
* @returns {string} Section text.
|
|
1023
|
+
*/
|
|
1024
|
+
function defaultNone(value) {
|
|
1025
|
+
const text = String(value || "").trim();
|
|
1026
|
+
return text || "无";
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Check whether a candidate build name matches the expected build name.
|
|
1031
|
+
* @param {string} candidate Candidate build name.
|
|
1032
|
+
* @param {string} expected Expected build name.
|
|
1033
|
+
* @returns {boolean} Whether names match.
|
|
1034
|
+
*/
|
|
1035
|
+
function isMatchingBuildName(candidate, expected) {
|
|
1036
|
+
return stripTrailingNumber(candidate).includes(expected) || expected.includes(stripTrailingNumber(candidate));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Find the best reference build for default creation values.
|
|
1041
|
+
* @param {unknown[]} builds Existing builds.
|
|
1042
|
+
* @param {string} account Current account.
|
|
1043
|
+
* @returns {unknown|undefined} Reference build.
|
|
1044
|
+
*/
|
|
1045
|
+
function findBestReferenceBuild(builds, account) {
|
|
1046
|
+
return builds.find((build) => sameAccount(String(build?.createdBy || build?.builder?.account || build?.builder || ""), account)) || builds[0];
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Validate a positive integer-like ID.
|
|
1051
|
+
* @param {number|string} value Raw ID.
|
|
1052
|
+
* @param {string} name Field name.
|
|
1053
|
+
* @returns {number} Positive integer.
|
|
1054
|
+
*/
|
|
1055
|
+
function assertPositiveId(value, name) {
|
|
1056
|
+
const id = Number(value);
|
|
1057
|
+
if (!Number.isInteger(id) || id <= 0) throw new Error(`${name} must be a positive integer.`);
|
|
1058
|
+
return id;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Validate a list of positive integer-like IDs.
|
|
1063
|
+
* @param {Array<number|string>|undefined} values Raw IDs.
|
|
1064
|
+
* @param {string} name Field name.
|
|
1065
|
+
* @returns {number[]} Positive integers.
|
|
1066
|
+
*/
|
|
1067
|
+
function assertPositiveIdList(values, name) {
|
|
1068
|
+
if (!Array.isArray(values) || values.length === 0) throw new Error(`${name} must contain at least one ID.`);
|
|
1069
|
+
return values.map((value) => assertPositiveId(value, name));
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Return unique positive numbers in input order.
|
|
1074
|
+
* @param {number[]} values Raw numbers.
|
|
1075
|
+
* @returns {number[]} Unique numbers.
|
|
1076
|
+
*/
|
|
1077
|
+
function uniqueNumbers(values) {
|
|
1078
|
+
return [...new Set(values.filter((value) => Number.isInteger(value) && value > 0))];
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Clamp page size to ZenTao's documented upper bound.
|
|
1083
|
+
* @param {number|undefined} value Raw page size.
|
|
1084
|
+
* @returns {number} Page size.
|
|
1085
|
+
*/
|
|
1086
|
+
function clampRecPerPage(value) {
|
|
1087
|
+
const recPerPage = Number(value ?? 100);
|
|
1088
|
+
if (!Number.isInteger(recPerPage) || recPerPage <= 0) throw new Error("recPerPage must be a positive integer.");
|
|
1089
|
+
return Math.min(recPerPage, MAX_REC_PER_PAGE);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Fetch with a timeout so MCP calls do not hang indefinitely.
|
|
1094
|
+
* @param {typeof fetch} fetchImpl Fetch implementation.
|
|
1095
|
+
* @param {string} url Request URL.
|
|
1096
|
+
* @param {RequestInit} options Fetch options.
|
|
1097
|
+
* @param {number} timeoutMs Timeout in milliseconds.
|
|
1098
|
+
* @returns {Promise<Response>} Fetch response.
|
|
1099
|
+
*/
|
|
1100
|
+
async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) {
|
|
1101
|
+
const controller = new AbortController();
|
|
1102
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1103
|
+
try {
|
|
1104
|
+
return await fetchImpl(url, { ...options, signal: controller.signal });
|
|
1105
|
+
} finally {
|
|
1106
|
+
clearTimeout(timer);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Read a JSON response and report malformed payloads clearly.
|
|
1112
|
+
* @param {Response} response Fetch response.
|
|
1113
|
+
* @returns {Promise<unknown>} Parsed JSON.
|
|
1114
|
+
*/
|
|
1115
|
+
async function readJsonResponse(response) {
|
|
1116
|
+
const text = await response.text();
|
|
1117
|
+
if (!text) return {};
|
|
1118
|
+
try {
|
|
1119
|
+
return JSON.parse(text);
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
throw new ZentaoApiError("ZenTao API returned non-JSON response.", { statusCode: response.status, response: text.slice(0, 1000) });
|
|
1122
|
+
}
|
|
1123
|
+
}
|