@dyno181cm.nexsoft/zentao_mcp 1.4.3 → 1.5.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.
@@ -1,9 +1,6 @@
1
1
  import { htmlToMarkdown, parseFiles } from "../utils/markdownUtils.js";
2
2
  import { localizeImages } from "./imageLocalizer.js";
3
- import { toFileUrl, formatSize } from "../utils/fileUtils.js";
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
- if (act.files && Array.isArray(act.files) && act.files.length > 0) {
49
+ if (act.files) {
53
50
  const parsedFiles = parseFiles(act.files);
54
51
  if (parsedFiles.length > 0) {
55
- const fileLinks = [];
56
- for (const f of parsedFiles) {
57
- const ext = (f.extension || '').toLowerCase();
58
- const targetPath = path.join(os.tmpdir(), `zentao_file_${f.id}.${ext}`);
59
- let localPath = null;
60
- try {
61
- localPath = fs.existsSync(targetPath)
62
- ? targetPath
63
- : await client.downloadFile(f.id, targetPath);
64
- if (localPath && downloadedImages && !downloadedImages.includes(localPath)) {
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.
@@ -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",
@@ -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
  }
@@ -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
  }
@@ -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); // Fix #8: filter null/undefined items
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
- for (const imgPath of uniquePaths) {
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
- continue;
25
- const stats = fs.statSync(imgPath);
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
- continue;
29
- const data = fs.readFileSync(imgPath).toString("base64");
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
  }
@@ -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,21 @@ 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
- /** Fix #3: In-flight request map prevents cache stampede under concurrent calls. */
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
+ // ─── Cache helpers ────────────────────────────────────────────────────────────
20
56
  /**
21
57
  * Clears the in-memory cache for GET requests. Helpful for testing.
22
58
  */
@@ -35,7 +71,7 @@ export class ZentaoClient {
35
71
  if (cached && now - cached.timestamp < this.cacheTtlMs) {
36
72
  return cached.data;
37
73
  }
38
- // Fix #3: If a request for this URL is already in-flight, reuse its promise
74
+ // If a request for this URL is already in-flight, reuse its promise
39
75
  // instead of firing a duplicate HTTP request (prevents cache stampede).
40
76
  if (this.inFlightRequests.has(url)) {
41
77
  return this.inFlightRequests.get(url);
@@ -52,6 +88,7 @@ export class ZentaoClient {
52
88
  this.inFlightRequests.set(url, requestPromise);
53
89
  return requestPromise;
54
90
  }
91
+ // ─── Constructor & interceptors ───────────────────────────────────────────────
55
92
  /**
56
93
  * Initializes the Axios client with base configurations and registers interceptors
57
94
  * to automatically inject token headers and handle token expiration (401 errors).
@@ -92,6 +129,7 @@ export class ZentaoClient {
92
129
  return Promise.reject(error);
93
130
  });
94
131
  }
132
+ // ─── Authentication ───────────────────────────────────────────────────────────
95
133
  /**
96
134
  * Performs authentication request to Zentao API to obtain a token.
97
135
  * Updates the internal token state upon successful login.
@@ -113,6 +151,7 @@ export class ZentaoClient {
113
151
  throw new Error('Login failed: Token not found in response');
114
152
  }
115
153
  }
154
+ // ─── Private helpers ──────────────────────────────────────────────────────────
116
155
  /**
117
156
  * Helper to generate standard action descriptions when they are not present in classic API.
118
157
  */
@@ -122,20 +161,8 @@ export class ZentaoClient {
122
161
  if (userMap[extraName]) {
123
162
  extraName = userMap[extraName];
124
163
  }
125
- else {
126
- const resMap = {
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
- }
164
+ else if (RESOLUTION_LABEL_MAP[extraName]) {
165
+ extraName = RESOLUTION_LABEL_MAP[extraName];
139
166
  }
140
167
  switch (act.action) {
141
168
  case 'opened':
@@ -175,10 +202,7 @@ export class ZentaoClient {
175
202
  * Fetches classic JSON API fallback data when REST API returns empty/errors.
176
203
  */
177
204
  async getFallbackClassicData(type, id) {
178
- const webBaseUrl = this.baseUrl.includes('/api.php/v1')
179
- ? this.baseUrl.split('/api.php/v1')[0]
180
- : this.baseUrl.split('/api/v1')[0];
181
- const url = `${webBaseUrl}/${type}-view-${id}.json`;
205
+ const url = `${this.webBaseUrl}/${type}-view-${id}.json`;
182
206
  const res = await this.client.get(url, {
183
207
  baseURL: '', // override baseURL
184
208
  });
@@ -190,8 +214,7 @@ export class ZentaoClient {
190
214
  if (entity) {
191
215
  const userMap = detailData.users || {};
192
216
  // Normalize user fields
193
- const userFields = ['openedBy', 'assignedTo', 'resolvedBy', 'closedBy', 'finishedBy', 'canceledBy', 'lastEditedBy'];
194
- for (const field of userFields) {
217
+ for (const field of USER_FIELDS) {
195
218
  const val = entity[field];
196
219
  if (val && typeof val === 'string') {
197
220
  entity[field] = {
@@ -227,6 +250,60 @@ export class ZentaoClient {
227
250
  }
228
251
  throw new Error(`Failed to fetch ${type} details from classic fallback API for ID ${id}`);
229
252
  }
253
+ /**
254
+ * Generic helper that fetches entity details from the REST API and falls back
255
+ * to the classic JSON API when the REST response is empty or throws an error.
256
+ * Caches the fallback result under the REST API key so subsequent calls are fast.
257
+ *
258
+ * @param restUrl The REST API path (e.g. `/tasks/1`).
259
+ * @param type Entity type passed to `getFallbackClassicData`.
260
+ * @param id Entity ID passed to `getFallbackClassicData`.
261
+ */
262
+ async getWithFallback(restUrl, type, id) {
263
+ const runFallback = async () => {
264
+ const data = await this.getFallbackClassicData(type, id);
265
+ // Cache the fallback result under the REST key so repeated calls are fast.
266
+ this.getCache.set(restUrl, { data, timestamp: Date.now() });
267
+ return data;
268
+ };
269
+ try {
270
+ const data = await this.get(restUrl);
271
+ if (!data || data === '') {
272
+ return runFallback();
273
+ }
274
+ return data;
275
+ }
276
+ catch {
277
+ return runFallback();
278
+ }
279
+ }
280
+ /**
281
+ * Shared stream-to-file helper used by `downloadFile` and `downloadImageToLocal`.
282
+ * Pipes a readable stream to a local file path, removing the partial file on error.
283
+ *
284
+ * @param readable A Node.js Readable stream (axios response body).
285
+ * @param targetPath The local file path to write to.
286
+ * @returns Resolves with `targetPath` on success.
287
+ */
288
+ pipeStreamToFile(readable, targetPath) {
289
+ const writer = fs.createWriteStream(targetPath);
290
+ return new Promise((resolve, reject) => {
291
+ readable.pipe(writer);
292
+ let writeError = null;
293
+ writer.on('error', (err) => {
294
+ writeError = err;
295
+ writer.close();
296
+ // Remove the partial/corrupt file so future calls don't mistake it for a valid download.
297
+ fs.unlink(targetPath, () => { });
298
+ reject(err);
299
+ });
300
+ writer.on('close', () => {
301
+ if (!writeError)
302
+ resolve(targetPath);
303
+ });
304
+ });
305
+ }
306
+ // ─── Public API ───────────────────────────────────────────────────────────────
230
307
  /**
231
308
  * Retrieves details of a specific task.
232
309
  * Checks the cache first, otherwise fetches from API.
@@ -236,25 +313,7 @@ export class ZentaoClient {
236
313
  * @returns Resolves with the task details.
237
314
  */
238
315
  async getTaskDetails(taskId) {
239
- try {
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
- }
316
+ return this.getWithFallback(`/tasks/${taskId}`, 'task', taskId);
258
317
  }
259
318
  /**
260
319
  * Retrieves details of a specific bug.
@@ -265,25 +324,7 @@ export class ZentaoClient {
265
324
  * @returns Resolves with the bug details.
266
325
  */
267
326
  async getBugDetails(bugId) {
268
- try {
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
- }
327
+ return this.getWithFallback(`/bugs/${bugId}`, 'bug', bugId);
287
328
  }
288
329
  /**
289
330
  * Downloads a file from Zentao API and pipes it to a local file path.
@@ -293,25 +334,10 @@ export class ZentaoClient {
293
334
  * @returns A promise that resolves to the targetPath upon success, or rejects with an error.
294
335
  */
295
336
  async downloadFile(fileId, targetPath) {
296
- const writer = fs.createWriteStream(targetPath);
297
337
  const res = await this.client.get(`/files/${fileId}`, {
298
338
  responseType: 'stream',
299
339
  });
300
- return new Promise((resolve, reject) => {
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
- });
340
+ return this.pipeStreamToFile(res.data, targetPath);
315
341
  }
316
342
  /**
317
343
  * Downloads an image from a full authenticated URL and saves it to a local path.
@@ -322,26 +348,11 @@ export class ZentaoClient {
322
348
  * @returns A promise that resolves to the targetPath upon success, or rejects with an error.
323
349
  */
324
350
  async downloadImageToLocal(imageUrl, targetPath) {
325
- const writer = fs.createWriteStream(targetPath);
326
351
  const res = await this.client.get(imageUrl, {
327
352
  baseURL: '', // override baseURL so the full URL is used as-is
328
353
  responseType: 'stream',
329
354
  });
330
- return new Promise((resolve, reject) => {
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
- });
355
+ return this.pipeStreamToFile(res.data, targetPath);
345
356
  }
346
357
  /**
347
358
  * Helper method to perform POST requests.
@@ -387,10 +398,7 @@ export class ZentaoClient {
387
398
  if (!this.token) {
388
399
  await this.login();
389
400
  }
390
- const webBaseUrl = this.baseUrl.includes('/api.php/v1')
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}`;
401
+ const url = `${this.webBaseUrl}/action-comment-${type}-${id}.json?zentaosid=${this.token}`;
394
402
  // Clear the cache so subsequent fetches get the new comment
395
403
  this.clearCache();
396
404
  const params = new URLSearchParams();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyno181cm.nexsoft/zentao_mcp",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "description": "Zentao MCP Server",
5
5
  "repository": {
6
6
  "type": "git",