@dyno181cm.nexsoft/zentao_mcp 1.4.4 → 1.6.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/README.md +28 -8
- package/build/formatters/actionFormatter.js +12 -29
- package/build/formatters/imageLocalizer.js +2 -0
- package/build/formatters/myWorkFormatter.js +55 -0
- package/build/tools/bugTool.js +1 -1
- package/build/tools/commentTool.js +1 -1
- package/build/tools/myWorkTool.js +18 -0
- package/build/tools/statusTool.js +2 -2
- package/build/tools/taskTool.js +1 -1
- package/build/tools.js +2 -0
- package/build/utils/markdownUtils.js +6 -2
- package/build/utils/mcpResponse.js +11 -10
- package/build/zentaoClient.js +118 -96
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
[](https://www.typescriptlang.org)
|
|
9
9
|
[](https://modelcontextprotocol.io)
|
|
10
|
+
[](https://github.com/dyno-nexsoft/zentao_mcp/tree/master/tests)
|
|
10
11
|
|
|
11
12
|
A **Model Context Protocol (MCP)** server for integrating AI assistants (Claude, Cursor, etc.) with the [ZenTao](https://www.zentao.net) project management API.
|
|
12
13
|
Fetch task details, bug reports, and attachments — all directly inside your AI chat.
|
|
@@ -21,6 +22,7 @@ Fetch task details, bug reports, and attachments — all directly inside your AI
|
|
|
21
22
|
| `zentao_get_bug_details` | Fetch full details of a bug by ID (severity, repro steps, history & comments, inline images, attachments) |
|
|
22
23
|
| `zentao_download_attachment` | Download any ZenTao file attachment to local disk |
|
|
23
24
|
| `zentao_get_comments` | Fetch only the history and comments timeline of a task or bug |
|
|
25
|
+
| `zentao_add_comment` | Add a comment/remark to a task or bug |
|
|
24
26
|
| `zentao_update_task_status` | Update task status (start, finish, close, pause, cancel, restart) and add optional comments/hours |
|
|
25
27
|
| `zentao_update_bug_status` | Update bug status (resolve, close, activate) and add optional comments/resolutions |
|
|
26
28
|
|
|
@@ -28,9 +30,11 @@ Fetch task details, bug reports, and attachments — all directly inside your AI
|
|
|
28
30
|
**Under the hood:**
|
|
29
31
|
- 🔐 Auto login & token refresh — no manual auth needed
|
|
30
32
|
- 📦 In-memory cache (2 min TTL) + in-flight request dedup — avoids redundant API calls
|
|
33
|
+
- 🔁 Classic JSON API fallback — seamlessly retries via the web API when the REST endpoint returns empty or errors
|
|
31
34
|
- 🖼️ Inline HTML images are automatically downloaded and served as local `file://` links
|
|
32
35
|
- 📎 Attachments are downloaded and embedded as clickable local links
|
|
33
36
|
- 🛡️ Corrupt partial downloads are auto-cleaned on error
|
|
37
|
+
- ⚡ Non-blocking async image I/O — base64 encoding uses `fs.promises` + `Promise.all`
|
|
34
38
|
|
|
35
39
|
---
|
|
36
40
|
|
|
@@ -130,6 +134,15 @@ Get only the history and comments timeline of a task or bug.
|
|
|
130
134
|
| `type` | `"task" \| "bug"` | ✅ | Type of the object |
|
|
131
135
|
| `id` | `string \| number` | ✅ | Task or Bug ID |
|
|
132
136
|
|
|
137
|
+
### `zentao_add_comment`
|
|
138
|
+
Add a comment/remark to a task or bug.
|
|
139
|
+
|
|
140
|
+
| Parameter | Type | Required | Description |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| `type` | `"task" \| "bug"` | ✅ | Type of the object |
|
|
143
|
+
| `id` | `string \| number` | ✅ | Task or Bug ID |
|
|
144
|
+
| `comment` | `string` | ✅ | Comment content |
|
|
145
|
+
|
|
133
146
|
### `zentao_update_task_status`
|
|
134
147
|
Update a task's status with optional comments, actual start/finish dates, and consumed hours.
|
|
135
148
|
|
|
@@ -174,24 +187,31 @@ npm run test:api # Live API integration test
|
|
|
174
187
|
```
|
|
175
188
|
src/
|
|
176
189
|
├── index.ts # MCP server entry point
|
|
177
|
-
├── zentaoClient.ts # Axios client: auth, cache, dedup,
|
|
190
|
+
├── zentaoClient.ts # Axios client: auth, cache, dedup, fallback, stream helpers
|
|
178
191
|
├── tools.ts # Thin orchestrator + backward-compat exports
|
|
179
192
|
├── utils/
|
|
180
193
|
│ ├── fileUtils.ts # toFileUrl · getMimeType · formatSize
|
|
181
194
|
│ ├── markdownUtils.ts # htmlToMarkdown · parseFiles · formatUser
|
|
182
|
-
│ └── mcpResponse.ts # mcpText · buildMcpResponse
|
|
195
|
+
│ └── mcpResponse.ts # mcpText · buildMcpResponse (async)
|
|
183
196
|
├── formatters/
|
|
184
|
-
│ ├── imageLocalizer.ts # Inline <img> → local file:// link
|
|
197
|
+
│ ├── imageLocalizer.ts # Inline <img> → local file:// link (deduped, concurrent)
|
|
185
198
|
│ ├── attachmentRenderer.ts # Attachments → Markdown ## Files section
|
|
186
199
|
│ ├── actionFormatter.ts # renderHistoryAndComments
|
|
187
200
|
│ ├── taskFormatter.ts # taskToMarkdown (includes comments)
|
|
188
201
|
│ └── bugFormatter.ts # bugToMarkdown (includes comments)
|
|
189
202
|
└── tools/
|
|
190
|
-
├── taskTool.ts # zentao_get_task_details
|
|
191
|
-
├── bugTool.ts # zentao_get_bug_details
|
|
192
|
-
├── downloadTool.ts # zentao_download_attachment
|
|
193
|
-
├── commentTool.ts # zentao_get_comments
|
|
194
|
-
└── statusTool.ts #
|
|
203
|
+
├── taskTool.ts # zentao_get_task_details
|
|
204
|
+
├── bugTool.ts # zentao_get_bug_details
|
|
205
|
+
├── downloadTool.ts # zentao_download_attachment
|
|
206
|
+
├── commentTool.ts # zentao_get_comments · zentao_add_comment
|
|
207
|
+
└── statusTool.ts # zentao_update_task_status · zentao_update_bug_status
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Running tests
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
npm test # Unit tests — 26 tests across ZentaoClient + formatters
|
|
214
|
+
npm run test:api # Live API integration test (requires .env)
|
|
195
215
|
```
|
|
196
216
|
|
|
197
217
|
---
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { htmlToMarkdown, parseFiles } from "../utils/markdownUtils.js";
|
|
2
2
|
import { localizeImages } from "./imageLocalizer.js";
|
|
3
|
-
import {
|
|
4
|
-
import * as path from "path";
|
|
5
|
-
import * as os from "os";
|
|
6
|
-
import fs from "fs";
|
|
3
|
+
import { renderAttachments } from "./attachmentRenderer.js";
|
|
7
4
|
/**
|
|
8
5
|
* Normalizes and formats the history/comments/actions list into a clean Markdown block.
|
|
9
6
|
*
|
|
@@ -47,36 +44,22 @@ export async function renderHistoryAndComments(actions, client, downloadedImages
|
|
|
47
44
|
commentStr = `\n > ${commentMd.replace(/\n/g, '\n > ')}`;
|
|
48
45
|
}
|
|
49
46
|
}
|
|
50
|
-
// 3. Format files if present in action
|
|
47
|
+
// 3. Format files if present in action — delegated to renderAttachments
|
|
51
48
|
let filesStr = "";
|
|
52
49
|
if (act.files) {
|
|
53
50
|
const parsedFiles = parseFiles(act.files);
|
|
54
51
|
if (parsedFiles.length > 0) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
downloadedImages.push(localPath);
|
|
66
|
-
}
|
|
52
|
+
// renderAttachments returns a full "## Files\n..." block; for inline action
|
|
53
|
+
// files we extract only the list items to keep the timeline compact.
|
|
54
|
+
const attachmentBlock = await renderAttachments(parsedFiles, client, downloadedImages);
|
|
55
|
+
if (attachmentBlock) {
|
|
56
|
+
// Strip the section header and trim, then indent as a sub-item.
|
|
57
|
+
const listContent = attachmentBlock
|
|
58
|
+
.replace(/^\s*## Files\s*\n/, '')
|
|
59
|
+
.trim();
|
|
60
|
+
if (listContent) {
|
|
61
|
+
filesStr = `\n - *Attachments:* ${listContent.replace(/^- /gm, '').replace(/\n- /g, ', ')}`;
|
|
67
62
|
}
|
|
68
|
-
catch {
|
|
69
|
-
// ignore download error
|
|
70
|
-
}
|
|
71
|
-
if (localPath) {
|
|
72
|
-
fileLinks.push(`[${f.title}](${toFileUrl(localPath)}) *(Size: ${formatSize(f.size)})*`);
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
fileLinks.push(`[${f.title}] *(download failed)*`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (fileLinks.length > 0) {
|
|
79
|
-
filesStr = `\n - *Attachments:* ${fileLinks.join(", ")}`;
|
|
80
63
|
}
|
|
81
64
|
}
|
|
82
65
|
}
|
|
@@ -71,6 +71,8 @@ export async function localizeImages(html, client, downloadedImages) {
|
|
|
71
71
|
}
|
|
72
72
|
catch {
|
|
73
73
|
// Keep original tag if download fails.
|
|
74
|
+
// Ensure in-flight entry is cleared even on unexpected errors.
|
|
75
|
+
inFlightImageDownloads.delete(remoteUrl);
|
|
74
76
|
}
|
|
75
77
|
}));
|
|
76
78
|
// Phase 2: Apply all replacements sequentially on the original string.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a raw ZenTao "my work" response object into a human-readable Markdown document.
|
|
3
|
+
*
|
|
4
|
+
* @param myWork The raw "my work" object containing profile, task, and bug lists.
|
|
5
|
+
* @param client Authenticated ZentaoClient to get baseUrl for linking.
|
|
6
|
+
*/
|
|
7
|
+
export function myWorkToMarkdown(myWork, client) {
|
|
8
|
+
if (!myWork)
|
|
9
|
+
return "No work items found.";
|
|
10
|
+
const profile = myWork.profile || {};
|
|
11
|
+
const taskData = myWork.task || { total: 0, tasks: [] };
|
|
12
|
+
const bugData = myWork.bug || { total: 0, bugs: [] };
|
|
13
|
+
const webUrl = client.webUrl || "";
|
|
14
|
+
const header = [
|
|
15
|
+
`# Work Items Assigned to ${profile.realname || profile.account || "Me"}`,
|
|
16
|
+
`- **Account:** ${profile.account || "N/A"}`,
|
|
17
|
+
`- **Role:** ${profile.role?.name || "N/A"}`,
|
|
18
|
+
`- **Total Tasks:** ${taskData.total}`,
|
|
19
|
+
`- **Total Bugs:** ${bugData.total}`,
|
|
20
|
+
""
|
|
21
|
+
];
|
|
22
|
+
const sections = [...header];
|
|
23
|
+
// ── Tasks Section ──
|
|
24
|
+
sections.push("## Tasks Assigned to Me");
|
|
25
|
+
if (taskData.total === 0 || !taskData.tasks || taskData.tasks.length === 0) {
|
|
26
|
+
sections.push("*No tasks assigned to you.*", "");
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
sections.push("| ID | Task Name | Pri | Status | Execution | Deadline | Progress |", "| :--- | :--- | :---: | :---: | :--- | :---: | :---: |");
|
|
30
|
+
for (const t of taskData.tasks) {
|
|
31
|
+
const taskLink = webUrl ? `[${t.id}](${webUrl}/task-view-${t.id}.html)` : `${t.id}`;
|
|
32
|
+
const execution = t.executionName || "N/A";
|
|
33
|
+
const prog = t.progress ?? 0;
|
|
34
|
+
const deadline = t.deadline || "N/A";
|
|
35
|
+
sections.push(`| ${taskLink} | ${t.name} | ${t.pri} | ${t.status} | ${execution} | ${deadline} | ${prog}% |`);
|
|
36
|
+
}
|
|
37
|
+
sections.push("");
|
|
38
|
+
}
|
|
39
|
+
// ── Bugs Section ──
|
|
40
|
+
sections.push("## Bugs Assigned to Me");
|
|
41
|
+
if (bugData.total === 0 || !bugData.bugs || bugData.bugs.length === 0) {
|
|
42
|
+
sections.push("*No bugs assigned to you.*", "");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
sections.push("| ID | Bug Title | Pri | Status | Product | Deadline |", "| :--- | :--- | :---: | :---: | :--- | :---: |");
|
|
46
|
+
for (const b of bugData.bugs) {
|
|
47
|
+
const bugLink = webUrl ? `[${b.id}](${webUrl}/bug-view-${b.id}.html)` : `${b.id}`;
|
|
48
|
+
const product = b.productName || "N/A";
|
|
49
|
+
const deadline = b.deadline || "N/A";
|
|
50
|
+
sections.push(`| ${bugLink} | ${b.title} | ${b.pri} | ${b.status} | ${product} | ${deadline} |`);
|
|
51
|
+
}
|
|
52
|
+
sections.push("");
|
|
53
|
+
}
|
|
54
|
+
return sections.join("\n");
|
|
55
|
+
}
|
package/build/tools/bugTool.js
CHANGED
|
@@ -17,6 +17,6 @@ export function registerBugTool(server, client) {
|
|
|
17
17
|
const data = await client.getBugDetails(bugId);
|
|
18
18
|
const downloadedImages = [];
|
|
19
19
|
const markdown = await bugToMarkdown(data, client, downloadedImages);
|
|
20
|
-
return buildMcpResponse(markdown, downloadedImages);
|
|
20
|
+
return await buildMcpResponse(markdown, downloadedImages);
|
|
21
21
|
});
|
|
22
22
|
}
|
|
@@ -29,7 +29,7 @@ export function registerCommentTool(server, client) {
|
|
|
29
29
|
`# History & Comments for ${title}`,
|
|
30
30
|
markdown.trim() || "*No history or comments found.*"
|
|
31
31
|
].join("\n\n");
|
|
32
|
-
return buildMcpResponse(output, downloadedImages);
|
|
32
|
+
return await buildMcpResponse(output, downloadedImages);
|
|
33
33
|
});
|
|
34
34
|
server.registerTool("zentao_add_comment", {
|
|
35
35
|
description: "Add a comment/remark to a task or a bug",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { myWorkToMarkdown } from "../formatters/myWorkFormatter.js";
|
|
2
|
+
import { mcpText } from "../utils/mcpResponse.js";
|
|
3
|
+
/**
|
|
4
|
+
* Register the `zentao_get_assigned_to_me` MCP tool on the given server.
|
|
5
|
+
*
|
|
6
|
+
* @param server The MCP Server instance.
|
|
7
|
+
* @param client Authenticated ZentaoClient used to fetch and process data.
|
|
8
|
+
*/
|
|
9
|
+
export function registerMyWorkTool(server, client) {
|
|
10
|
+
server.registerTool("zentao_get_assigned_to_me", {
|
|
11
|
+
description: "Get tasks and bugs assigned to the current user (configured in environment variables)",
|
|
12
|
+
inputSchema: {},
|
|
13
|
+
}, async () => {
|
|
14
|
+
const data = await client.getMyWork();
|
|
15
|
+
const markdown = myWorkToMarkdown(data, client);
|
|
16
|
+
return mcpText(markdown);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -54,7 +54,7 @@ export function registerStatusTools(server, client) {
|
|
|
54
54
|
const downloadedImages = [];
|
|
55
55
|
const markdown = await taskToMarkdown(updatedTask, client, downloadedImages);
|
|
56
56
|
const output = `### Successfully updated task status to '${updatedTask.status || "updated"}'!\n\n${markdown}`;
|
|
57
|
-
return buildMcpResponse(output, downloadedImages);
|
|
57
|
+
return await buildMcpResponse(output, downloadedImages);
|
|
58
58
|
});
|
|
59
59
|
// ─── Bug Status Update Tool ────────────────────────────────────────────────
|
|
60
60
|
server.registerTool("zentao_update_bug_status", {
|
|
@@ -90,6 +90,6 @@ export function registerStatusTools(server, client) {
|
|
|
90
90
|
const downloadedImages = [];
|
|
91
91
|
const markdown = await bugToMarkdown(updatedBug, client, downloadedImages);
|
|
92
92
|
const output = `### Successfully updated bug status to '${updatedBug.status || "updated"}'!\n\n${markdown}`;
|
|
93
|
-
return buildMcpResponse(output, downloadedImages);
|
|
93
|
+
return await buildMcpResponse(output, downloadedImages);
|
|
94
94
|
});
|
|
95
95
|
}
|
package/build/tools/taskTool.js
CHANGED
|
@@ -17,6 +17,6 @@ export function registerTaskTool(server, client) {
|
|
|
17
17
|
const data = await client.getTaskDetails(taskId);
|
|
18
18
|
const downloadedImages = [];
|
|
19
19
|
const markdown = await taskToMarkdown(data, client, downloadedImages);
|
|
20
|
-
return buildMcpResponse(markdown, downloadedImages);
|
|
20
|
+
return await buildMcpResponse(markdown, downloadedImages);
|
|
21
21
|
});
|
|
22
22
|
}
|
package/build/tools.js
CHANGED
|
@@ -18,6 +18,7 @@ import { registerBugTool } from "./tools/bugTool.js";
|
|
|
18
18
|
import { registerDownloadTool } from "./tools/downloadTool.js";
|
|
19
19
|
import { registerCommentTool } from "./tools/commentTool.js";
|
|
20
20
|
import { registerStatusTools } from "./tools/statusTool.js";
|
|
21
|
+
import { registerMyWorkTool } from "./tools/myWorkTool.js";
|
|
21
22
|
// ─── Shared singleton ────────────────────────────────────────────────────────
|
|
22
23
|
const client = new ZentaoClient();
|
|
23
24
|
// ─── Backward-compatible re-exports ──────────────────────────────────────────
|
|
@@ -51,4 +52,5 @@ export function registerTools(server) {
|
|
|
51
52
|
registerDownloadTool(server, client);
|
|
52
53
|
registerCommentTool(server, client);
|
|
53
54
|
registerStatusTools(server, client);
|
|
55
|
+
registerMyWorkTool(server, client);
|
|
54
56
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import TurndownService from "turndown";
|
|
2
|
-
const turndownService = new TurndownService(
|
|
2
|
+
const turndownService = new TurndownService({
|
|
3
|
+
headingStyle: 'atx', // Use `#` headings instead of underline style
|
|
4
|
+
bulletListMarker: '-', // Consistent `-` for unordered lists
|
|
5
|
+
codeBlockStyle: 'fenced', // Use ``` for code blocks
|
|
6
|
+
});
|
|
3
7
|
/** Convert HTML to Markdown for better AI readability. */
|
|
4
8
|
export function htmlToMarkdown(html) {
|
|
5
9
|
if (!html)
|
|
@@ -19,7 +23,7 @@ export function parseFiles(files) {
|
|
|
19
23
|
size: f.size,
|
|
20
24
|
});
|
|
21
25
|
if (Array.isArray(files)) {
|
|
22
|
-
return files.filter(Boolean).map(mapper); //
|
|
26
|
+
return files.filter(Boolean).map(mapper); // filter null/undefined items
|
|
23
27
|
}
|
|
24
28
|
if (typeof files === "object") {
|
|
25
29
|
return Object.values(files).filter(Boolean).map(mapper);
|
|
@@ -10,28 +10,29 @@ export function mcpText(text) {
|
|
|
10
10
|
*
|
|
11
11
|
* Images are deduplicated and limited to the first 5 to stay within
|
|
12
12
|
* JSON-RPC / token limits. Individual images larger than 2 MB are skipped.
|
|
13
|
+
*
|
|
14
|
+
* Uses async file I/O (`fs.promises`) to avoid blocking the event loop
|
|
15
|
+
* while reading image data.
|
|
13
16
|
*/
|
|
14
|
-
export function buildMcpResponse(markdown, imagePaths) {
|
|
17
|
+
export async function buildMcpResponse(markdown, imagePaths) {
|
|
15
18
|
const content = [{ type: "text", text: markdown }];
|
|
16
19
|
// Deduplicate and cap at 5 images
|
|
17
20
|
const uniquePaths = Array.from(new Set(imagePaths)).slice(0, 5);
|
|
18
|
-
|
|
21
|
+
await Promise.all(uniquePaths.map(async (imgPath) => {
|
|
19
22
|
try {
|
|
20
|
-
if (!fs.existsSync(imgPath))
|
|
21
|
-
continue;
|
|
22
23
|
const mimeType = getMimeType(imgPath);
|
|
23
24
|
if (!mimeType)
|
|
24
|
-
|
|
25
|
-
const stats = fs.
|
|
25
|
+
return;
|
|
26
|
+
const stats = await fs.promises.stat(imgPath);
|
|
26
27
|
// Skip images > 2 MB to avoid hitting JSON-RPC / token limits
|
|
27
28
|
if (stats.size > 2 * 1024 * 1024)
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
content.push({ type: "image", data, mimeType });
|
|
29
|
+
return;
|
|
30
|
+
const buffer = await fs.promises.readFile(imgPath);
|
|
31
|
+
content.push({ type: "image", data: buffer.toString("base64"), mimeType });
|
|
31
32
|
}
|
|
32
33
|
catch {
|
|
33
34
|
// Ignore read errors for individual images
|
|
34
35
|
}
|
|
35
|
-
}
|
|
36
|
+
}));
|
|
36
37
|
return { content };
|
|
37
38
|
}
|
package/build/zentaoClient.js
CHANGED
|
@@ -2,6 +2,29 @@ import axios from 'axios';
|
|
|
2
2
|
import * as dotenv from 'dotenv';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
4
|
dotenv.config();
|
|
5
|
+
/**
|
|
6
|
+
* Resolution label map for bug actions.
|
|
7
|
+
* Defined at module level to avoid re-allocating the object on every call to
|
|
8
|
+
* `generateActionDesc`.
|
|
9
|
+
*/
|
|
10
|
+
const RESOLUTION_LABEL_MAP = {
|
|
11
|
+
fixed: '已解决',
|
|
12
|
+
design: '设计如此',
|
|
13
|
+
duplicate: '重复Bug',
|
|
14
|
+
external: '外部原因',
|
|
15
|
+
notrepro: '无法重现',
|
|
16
|
+
postponed: '延期处理',
|
|
17
|
+
willnotfix: '不予解决',
|
|
18
|
+
tostory: '转为需求',
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* User fields that may be stored as a plain account string in the classic API
|
|
22
|
+
* and need to be normalised to `{ account, realname }` objects.
|
|
23
|
+
*/
|
|
24
|
+
const USER_FIELDS = [
|
|
25
|
+
'openedBy', 'assignedTo', 'resolvedBy',
|
|
26
|
+
'closedBy', 'finishedBy', 'canceledBy', 'lastEditedBy',
|
|
27
|
+
];
|
|
5
28
|
/**
|
|
6
29
|
* Client for communicating with the Zentao API.
|
|
7
30
|
* Handles authentication, automatic token injection, session expiration retries,
|
|
@@ -15,8 +38,27 @@ export class ZentaoClient {
|
|
|
15
38
|
baseUrl = process.env.ZENTAO_BASE_URL || '';
|
|
16
39
|
getCache = new Map();
|
|
17
40
|
cacheTtlMs = 2 * 60 * 1000; // 2 minutes TTL
|
|
18
|
-
/**
|
|
41
|
+
/** In-flight request map prevents cache stampede under concurrent calls. */
|
|
19
42
|
inFlightRequests = new Map();
|
|
43
|
+
// ─── Computed properties ─────────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Returns the web base URL (without the API path suffix).
|
|
46
|
+
* Used by both `getFallbackClassicData` and `addComment` to avoid duplicating
|
|
47
|
+
* the same string-split logic in two places.
|
|
48
|
+
*/
|
|
49
|
+
get webBaseUrl() {
|
|
50
|
+
if (this.baseUrl.includes('/api.php/v1')) {
|
|
51
|
+
return this.baseUrl.split('/api.php/v1')[0];
|
|
52
|
+
}
|
|
53
|
+
return this.baseUrl.split('/api/v1')[0];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Public getter exposing the web base URL.
|
|
57
|
+
*/
|
|
58
|
+
get webUrl() {
|
|
59
|
+
return this.webBaseUrl;
|
|
60
|
+
}
|
|
61
|
+
// ─── Cache helpers ────────────────────────────────────────────────────────────
|
|
20
62
|
/**
|
|
21
63
|
* Clears the in-memory cache for GET requests. Helpful for testing.
|
|
22
64
|
*/
|
|
@@ -35,7 +77,7 @@ export class ZentaoClient {
|
|
|
35
77
|
if (cached && now - cached.timestamp < this.cacheTtlMs) {
|
|
36
78
|
return cached.data;
|
|
37
79
|
}
|
|
38
|
-
//
|
|
80
|
+
// If a request for this URL is already in-flight, reuse its promise
|
|
39
81
|
// instead of firing a duplicate HTTP request (prevents cache stampede).
|
|
40
82
|
if (this.inFlightRequests.has(url)) {
|
|
41
83
|
return this.inFlightRequests.get(url);
|
|
@@ -52,6 +94,7 @@ export class ZentaoClient {
|
|
|
52
94
|
this.inFlightRequests.set(url, requestPromise);
|
|
53
95
|
return requestPromise;
|
|
54
96
|
}
|
|
97
|
+
// ─── Constructor & interceptors ───────────────────────────────────────────────
|
|
55
98
|
/**
|
|
56
99
|
* Initializes the Axios client with base configurations and registers interceptors
|
|
57
100
|
* to automatically inject token headers and handle token expiration (401 errors).
|
|
@@ -92,6 +135,7 @@ export class ZentaoClient {
|
|
|
92
135
|
return Promise.reject(error);
|
|
93
136
|
});
|
|
94
137
|
}
|
|
138
|
+
// ─── Authentication ───────────────────────────────────────────────────────────
|
|
95
139
|
/**
|
|
96
140
|
* Performs authentication request to Zentao API to obtain a token.
|
|
97
141
|
* Updates the internal token state upon successful login.
|
|
@@ -113,6 +157,7 @@ export class ZentaoClient {
|
|
|
113
157
|
throw new Error('Login failed: Token not found in response');
|
|
114
158
|
}
|
|
115
159
|
}
|
|
160
|
+
// ─── Private helpers ──────────────────────────────────────────────────────────
|
|
116
161
|
/**
|
|
117
162
|
* Helper to generate standard action descriptions when they are not present in classic API.
|
|
118
163
|
*/
|
|
@@ -122,20 +167,8 @@ export class ZentaoClient {
|
|
|
122
167
|
if (userMap[extraName]) {
|
|
123
168
|
extraName = userMap[extraName];
|
|
124
169
|
}
|
|
125
|
-
else {
|
|
126
|
-
|
|
127
|
-
'fixed': '已解决',
|
|
128
|
-
'design': '设计如此',
|
|
129
|
-
'duplicate': '重复Bug',
|
|
130
|
-
'external': '外部原因',
|
|
131
|
-
'notrepro': '无法重现',
|
|
132
|
-
'postponed': '延期处理',
|
|
133
|
-
'willnotfix': '不予解决',
|
|
134
|
-
'tostory': '转为需求',
|
|
135
|
-
};
|
|
136
|
-
if (resMap[extraName]) {
|
|
137
|
-
extraName = resMap[extraName];
|
|
138
|
-
}
|
|
170
|
+
else if (RESOLUTION_LABEL_MAP[extraName]) {
|
|
171
|
+
extraName = RESOLUTION_LABEL_MAP[extraName];
|
|
139
172
|
}
|
|
140
173
|
switch (act.action) {
|
|
141
174
|
case 'opened':
|
|
@@ -175,10 +208,7 @@ export class ZentaoClient {
|
|
|
175
208
|
* Fetches classic JSON API fallback data when REST API returns empty/errors.
|
|
176
209
|
*/
|
|
177
210
|
async getFallbackClassicData(type, id) {
|
|
178
|
-
const
|
|
179
|
-
? this.baseUrl.split('/api.php/v1')[0]
|
|
180
|
-
: this.baseUrl.split('/api/v1')[0];
|
|
181
|
-
const url = `${webBaseUrl}/${type}-view-${id}.json`;
|
|
211
|
+
const url = `${this.webBaseUrl}/${type}-view-${id}.json`;
|
|
182
212
|
const res = await this.client.get(url, {
|
|
183
213
|
baseURL: '', // override baseURL
|
|
184
214
|
});
|
|
@@ -190,8 +220,7 @@ export class ZentaoClient {
|
|
|
190
220
|
if (entity) {
|
|
191
221
|
const userMap = detailData.users || {};
|
|
192
222
|
// Normalize user fields
|
|
193
|
-
const
|
|
194
|
-
for (const field of userFields) {
|
|
223
|
+
for (const field of USER_FIELDS) {
|
|
195
224
|
const val = entity[field];
|
|
196
225
|
if (val && typeof val === 'string') {
|
|
197
226
|
entity[field] = {
|
|
@@ -227,6 +256,68 @@ export class ZentaoClient {
|
|
|
227
256
|
}
|
|
228
257
|
throw new Error(`Failed to fetch ${type} details from classic fallback API for ID ${id}`);
|
|
229
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Generic helper that fetches entity details from the REST API and falls back
|
|
261
|
+
* to the classic JSON API when the REST response is empty or throws an error.
|
|
262
|
+
* Caches the fallback result under the REST API key so subsequent calls are fast.
|
|
263
|
+
*
|
|
264
|
+
* @param restUrl The REST API path (e.g. `/tasks/1`).
|
|
265
|
+
* @param type Entity type passed to `getFallbackClassicData`.
|
|
266
|
+
* @param id Entity ID passed to `getFallbackClassicData`.
|
|
267
|
+
*/
|
|
268
|
+
async getWithFallback(restUrl, type, id) {
|
|
269
|
+
const runFallback = async () => {
|
|
270
|
+
const data = await this.getFallbackClassicData(type, id);
|
|
271
|
+
// Cache the fallback result under the REST key so repeated calls are fast.
|
|
272
|
+
this.getCache.set(restUrl, { data, timestamp: Date.now() });
|
|
273
|
+
return data;
|
|
274
|
+
};
|
|
275
|
+
try {
|
|
276
|
+
const data = await this.get(restUrl);
|
|
277
|
+
if (!data || data === '') {
|
|
278
|
+
return runFallback();
|
|
279
|
+
}
|
|
280
|
+
return data;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return runFallback();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Shared stream-to-file helper used by `downloadFile` and `downloadImageToLocal`.
|
|
288
|
+
* Pipes a readable stream to a local file path, removing the partial file on error.
|
|
289
|
+
*
|
|
290
|
+
* @param readable A Node.js Readable stream (axios response body).
|
|
291
|
+
* @param targetPath The local file path to write to.
|
|
292
|
+
* @returns Resolves with `targetPath` on success.
|
|
293
|
+
*/
|
|
294
|
+
pipeStreamToFile(readable, targetPath) {
|
|
295
|
+
const writer = fs.createWriteStream(targetPath);
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
readable.pipe(writer);
|
|
298
|
+
let writeError = null;
|
|
299
|
+
writer.on('error', (err) => {
|
|
300
|
+
writeError = err;
|
|
301
|
+
writer.close();
|
|
302
|
+
// Remove the partial/corrupt file so future calls don't mistake it for a valid download.
|
|
303
|
+
fs.unlink(targetPath, () => { });
|
|
304
|
+
reject(err);
|
|
305
|
+
});
|
|
306
|
+
writer.on('close', () => {
|
|
307
|
+
if (!writeError)
|
|
308
|
+
resolve(targetPath);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
313
|
+
/**
|
|
314
|
+
* Retrieves tasks and bugs assigned to the current user (my work).
|
|
315
|
+
*
|
|
316
|
+
* @returns Resolves with the user's work items (tasks and bugs).
|
|
317
|
+
*/
|
|
318
|
+
async getMyWork() {
|
|
319
|
+
return this.get('/user?fields=task,bug&type=assignedTo');
|
|
320
|
+
}
|
|
230
321
|
/**
|
|
231
322
|
* Retrieves details of a specific task.
|
|
232
323
|
* Checks the cache first, otherwise fetches from API.
|
|
@@ -236,25 +327,7 @@ export class ZentaoClient {
|
|
|
236
327
|
* @returns Resolves with the task details.
|
|
237
328
|
*/
|
|
238
329
|
async getTaskDetails(taskId) {
|
|
239
|
-
|
|
240
|
-
const task = await this.get(`/tasks/${taskId}`);
|
|
241
|
-
if (!task || task === "") {
|
|
242
|
-
const fallbackTask = await this.getFallbackClassicData('task', taskId);
|
|
243
|
-
this.getCache.set(`/tasks/${taskId}`, { data: fallbackTask, timestamp: Date.now() });
|
|
244
|
-
return fallbackTask;
|
|
245
|
-
}
|
|
246
|
-
return task;
|
|
247
|
-
}
|
|
248
|
-
catch (error) {
|
|
249
|
-
try {
|
|
250
|
-
const fallbackTask = await this.getFallbackClassicData('task', taskId);
|
|
251
|
-
this.getCache.set(`/tasks/${taskId}`, { data: fallbackTask, timestamp: Date.now() });
|
|
252
|
-
return fallbackTask;
|
|
253
|
-
}
|
|
254
|
-
catch (fallbackError) {
|
|
255
|
-
throw error; // throw original error if fallback also fails
|
|
256
|
-
}
|
|
257
|
-
}
|
|
330
|
+
return this.getWithFallback(`/tasks/${taskId}`, 'task', taskId);
|
|
258
331
|
}
|
|
259
332
|
/**
|
|
260
333
|
* Retrieves details of a specific bug.
|
|
@@ -265,25 +338,7 @@ export class ZentaoClient {
|
|
|
265
338
|
* @returns Resolves with the bug details.
|
|
266
339
|
*/
|
|
267
340
|
async getBugDetails(bugId) {
|
|
268
|
-
|
|
269
|
-
const bug = await this.get(`/bugs/${bugId}`);
|
|
270
|
-
if (!bug || bug === "") {
|
|
271
|
-
const fallbackBug = await this.getFallbackClassicData('bug', bugId);
|
|
272
|
-
this.getCache.set(`/bugs/${bugId}`, { data: fallbackBug, timestamp: Date.now() });
|
|
273
|
-
return fallbackBug;
|
|
274
|
-
}
|
|
275
|
-
return bug;
|
|
276
|
-
}
|
|
277
|
-
catch (error) {
|
|
278
|
-
try {
|
|
279
|
-
const fallbackBug = await this.getFallbackClassicData('bug', bugId);
|
|
280
|
-
this.getCache.set(`/bugs/${bugId}`, { data: fallbackBug, timestamp: Date.now() });
|
|
281
|
-
return fallbackBug;
|
|
282
|
-
}
|
|
283
|
-
catch (fallbackError) {
|
|
284
|
-
throw error; // throw original error if fallback also fails
|
|
285
|
-
}
|
|
286
|
-
}
|
|
341
|
+
return this.getWithFallback(`/bugs/${bugId}`, 'bug', bugId);
|
|
287
342
|
}
|
|
288
343
|
/**
|
|
289
344
|
* Downloads a file from Zentao API and pipes it to a local file path.
|
|
@@ -293,25 +348,10 @@ export class ZentaoClient {
|
|
|
293
348
|
* @returns A promise that resolves to the targetPath upon success, or rejects with an error.
|
|
294
349
|
*/
|
|
295
350
|
async downloadFile(fileId, targetPath) {
|
|
296
|
-
const writer = fs.createWriteStream(targetPath);
|
|
297
351
|
const res = await this.client.get(`/files/${fileId}`, {
|
|
298
352
|
responseType: 'stream',
|
|
299
353
|
});
|
|
300
|
-
return
|
|
301
|
-
res.data.pipe(writer);
|
|
302
|
-
let error = null;
|
|
303
|
-
writer.on('error', (err) => {
|
|
304
|
-
error = err;
|
|
305
|
-
writer.close();
|
|
306
|
-
// Fix #4: remove the partial/corrupt file so future calls don't mistake it for a valid download.
|
|
307
|
-
fs.unlink(targetPath, () => { });
|
|
308
|
-
reject(err);
|
|
309
|
-
});
|
|
310
|
-
writer.on('close', () => {
|
|
311
|
-
if (!error)
|
|
312
|
-
resolve(targetPath);
|
|
313
|
-
});
|
|
314
|
-
});
|
|
354
|
+
return this.pipeStreamToFile(res.data, targetPath);
|
|
315
355
|
}
|
|
316
356
|
/**
|
|
317
357
|
* Downloads an image from a full authenticated URL and saves it to a local path.
|
|
@@ -322,26 +362,11 @@ export class ZentaoClient {
|
|
|
322
362
|
* @returns A promise that resolves to the targetPath upon success, or rejects with an error.
|
|
323
363
|
*/
|
|
324
364
|
async downloadImageToLocal(imageUrl, targetPath) {
|
|
325
|
-
const writer = fs.createWriteStream(targetPath);
|
|
326
365
|
const res = await this.client.get(imageUrl, {
|
|
327
366
|
baseURL: '', // override baseURL so the full URL is used as-is
|
|
328
367
|
responseType: 'stream',
|
|
329
368
|
});
|
|
330
|
-
return
|
|
331
|
-
res.data.pipe(writer);
|
|
332
|
-
let error = null;
|
|
333
|
-
writer.on('error', (err) => {
|
|
334
|
-
error = err;
|
|
335
|
-
writer.close();
|
|
336
|
-
// Fix #4: remove the partial/corrupt file so future calls don't mistake it for a valid download.
|
|
337
|
-
fs.unlink(targetPath, () => { });
|
|
338
|
-
reject(err);
|
|
339
|
-
});
|
|
340
|
-
writer.on('close', () => {
|
|
341
|
-
if (!error)
|
|
342
|
-
resolve(targetPath);
|
|
343
|
-
});
|
|
344
|
-
});
|
|
369
|
+
return this.pipeStreamToFile(res.data, targetPath);
|
|
345
370
|
}
|
|
346
371
|
/**
|
|
347
372
|
* Helper method to perform POST requests.
|
|
@@ -387,10 +412,7 @@ export class ZentaoClient {
|
|
|
387
412
|
if (!this.token) {
|
|
388
413
|
await this.login();
|
|
389
414
|
}
|
|
390
|
-
const
|
|
391
|
-
? this.baseUrl.split('/api.php/v1')[0]
|
|
392
|
-
: this.baseUrl.split('/api/v1')[0];
|
|
393
|
-
const url = `${webBaseUrl}/action-comment-${type}-${id}.json?zentaosid=${this.token}`;
|
|
415
|
+
const url = `${this.webBaseUrl}/action-comment-${type}-${id}.json?zentaosid=${this.token}`;
|
|
394
416
|
// Clear the cache so subsequent fetches get the new comment
|
|
395
417
|
this.clearCache();
|
|
396
418
|
const params = new URLSearchParams();
|