@dyno181cm.nexsoft/zentao_mcp 1.4.0 → 1.4.2

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,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { renderHistoryAndComments } from "../formatters/actionFormatter.js";
3
- import { buildMcpResponse } from "../utils/mcpResponse.js";
3
+ import { buildMcpResponse, mcpText } from "../utils/mcpResponse.js";
4
4
  /**
5
5
  * Register the `zentao_get_comments` MCP tool on the given server.
6
6
  *
@@ -31,4 +31,15 @@ export function registerCommentTool(server, client) {
31
31
  ].join("\n\n");
32
32
  return buildMcpResponse(output, downloadedImages);
33
33
  });
34
+ server.registerTool("zentao_add_comment", {
35
+ description: "Add a comment/remark to a task or a bug",
36
+ inputSchema: {
37
+ type: z.enum(["task", "bug"]).describe("Type of the object ('task' or 'bug')"),
38
+ id: z.union([z.string(), z.number()]).describe("Task or Bug ID"),
39
+ comment: z.string().describe("Comment content"),
40
+ },
41
+ }, async ({ type, id, comment }) => {
42
+ const result = await client.addComment(type, id, comment);
43
+ return mcpText(result.message);
44
+ });
34
45
  }
@@ -113,25 +113,177 @@ export class ZentaoClient {
113
113
  throw new Error('Login failed: Token not found in response');
114
114
  }
115
115
  }
116
+ /**
117
+ * Helper to generate standard action descriptions when they are not present in classic API.
118
+ */
119
+ generateActionDesc(act, userMap) {
120
+ const actorName = userMap[act.actor] || act.actor || 'User';
121
+ let extraName = act.extra || '';
122
+ if (userMap[extraName]) {
123
+ extraName = userMap[extraName];
124
+ }
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
+ }
139
+ }
140
+ switch (act.action) {
141
+ case 'opened':
142
+ return `由 <strong>${actorName}</strong> 创建。`;
143
+ case 'assigned':
144
+ return `由 <strong>${actorName}</strong> 指派给 <strong>${extraName}</strong>。`;
145
+ case 'started':
146
+ if (act.extra === 'autobychild') {
147
+ return `由 <strong>${actorName}</strong> 启动子任务,该任务自动启动。`;
148
+ }
149
+ return `由 <strong>${actorName}</strong> 启动。`;
150
+ case 'finished':
151
+ return `由 <strong>${actorName}</strong> 完成。`;
152
+ case 'resolved':
153
+ return `由 <strong>${actorName}</strong> 解决,方案为 <strong>${extraName}</strong>。`;
154
+ case 'closed':
155
+ return `由 <strong>${actorName}</strong> 关闭。`;
156
+ case 'edited':
157
+ return `由 <strong>${actorName}</strong> 编辑。`;
158
+ case 'bugconfirmed':
159
+ case 'confirmed':
160
+ return `由 <strong>${actorName}</strong> 确认Bug。`;
161
+ case 'activated':
162
+ return `由 <strong>${actorName}</strong> 激活。`;
163
+ case 'commented':
164
+ return `由 <strong>${actorName}</strong> 备注。`;
165
+ case 'createchildren':
166
+ return `由 <strong>${actorName}</strong> 创建子任务 ${act.extra || ''}。`;
167
+ default:
168
+ if (act.action && act.action.startsWith('subtask')) {
169
+ return `由 <strong>${actorName}</strong> ${act.action}。`;
170
+ }
171
+ return `由 <strong>${actorName}</strong> 执行了 <strong>${act.action || '未知'}</strong> 操作。`;
172
+ }
173
+ }
174
+ /**
175
+ * Fetches classic JSON API fallback data when REST API returns empty/errors.
176
+ */
177
+ 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`;
182
+ const res = await this.client.get(url, {
183
+ baseURL: '', // override baseURL
184
+ });
185
+ const responseData = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
186
+ if (responseData && responseData.status === 'success') {
187
+ const detailData = typeof responseData.data === 'string' ? JSON.parse(responseData.data) : responseData.data;
188
+ if (detailData) {
189
+ const entity = detailData[type];
190
+ if (entity) {
191
+ const userMap = detailData.users || {};
192
+ // Normalize user fields
193
+ const userFields = ['openedBy', 'assignedTo', 'resolvedBy', 'closedBy', 'finishedBy', 'canceledBy', 'lastEditedBy'];
194
+ for (const field of userFields) {
195
+ const val = entity[field];
196
+ if (val && typeof val === 'string') {
197
+ entity[field] = {
198
+ account: val,
199
+ realname: userMap[val] || val
200
+ };
201
+ }
202
+ }
203
+ if (entity.assignedTo?.account === 'closed') {
204
+ entity.assignedTo = null;
205
+ }
206
+ // Normalize actions list to sorted array
207
+ let actionsArray = [];
208
+ if (detailData.actions) {
209
+ if (Array.isArray(detailData.actions)) {
210
+ actionsArray = detailData.actions;
211
+ }
212
+ else if (typeof detailData.actions === 'object') {
213
+ actionsArray = Object.values(detailData.actions);
214
+ actionsArray.sort((a, b) => (Number(a.id) || 0) - (Number(b.id) || 0));
215
+ }
216
+ }
217
+ // Generate descriptions if missing
218
+ for (const act of actionsArray) {
219
+ if (!act.desc) {
220
+ act.desc = `${act.date || ''}, ` + this.generateActionDesc(act, userMap);
221
+ }
222
+ }
223
+ entity.actions = actionsArray;
224
+ return entity;
225
+ }
226
+ }
227
+ }
228
+ throw new Error(`Failed to fetch ${type} details from classic fallback API for ID ${id}`);
229
+ }
116
230
  /**
117
231
  * Retrieves details of a specific task.
118
232
  * Checks the cache first, otherwise fetches from API.
233
+ * Falls back to classic JSON API if REST API returns empty/invalid response.
119
234
  *
120
235
  * @param taskId The ID of the task.
121
236
  * @returns Resolves with the task details.
122
237
  */
123
238
  async getTaskDetails(taskId) {
124
- return this.get(`/tasks/${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
+ }
125
258
  }
126
259
  /**
127
260
  * Retrieves details of a specific bug.
128
261
  * Checks the cache first, otherwise fetches from API.
262
+ * Falls back to classic JSON API if REST API returns empty/invalid response.
129
263
  *
130
264
  * @param bugId The ID of the bug.
131
265
  * @returns Resolves with the bug details.
132
266
  */
133
267
  async getBugDetails(bugId) {
134
- return this.get(`/bugs/${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
+ }
135
287
  }
136
288
  /**
137
289
  * Downloads a file from Zentao API and pipes it to a local file path.
@@ -224,4 +376,38 @@ export class ZentaoClient {
224
376
  async updateBugStatus(bugId, action, payload) {
225
377
  return this.post(`/bugs/${bugId}/${action}`, payload);
226
378
  }
379
+ /**
380
+ * Adds a comment to a task or a bug using the classic action comment endpoint.
381
+ *
382
+ * @param type The object type ('task' or 'bug').
383
+ * @param id The ID of the object.
384
+ * @param comment The text of the comment to add.
385
+ */
386
+ async addComment(type, id, comment) {
387
+ if (!this.token) {
388
+ await this.login();
389
+ }
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}`;
394
+ // Clear the cache so subsequent fetches get the new comment
395
+ this.clearCache();
396
+ const params = new URLSearchParams();
397
+ params.append('comment', comment);
398
+ const res = await this.client.post(url, params, {
399
+ baseURL: '', // override baseURL
400
+ headers: {
401
+ 'Content-Type': 'application/x-www-form-urlencoded'
402
+ }
403
+ });
404
+ if (res.status === 200 && typeof res.data === 'string' && res.data.includes('reload')) {
405
+ return { result: 'success', message: 'Comment added successfully' };
406
+ }
407
+ const responseData = typeof res.data === 'string' && res.data.startsWith('{') ? JSON.parse(res.data) : res.data;
408
+ if (responseData && (responseData.status === 'success' || responseData.result === 'success')) {
409
+ return { result: 'success', message: 'Comment added successfully' };
410
+ }
411
+ throw new Error(`Failed to add comment to ${type} #${id}. Response: ${typeof res.data === 'string' ? res.data.substring(0, 200) : JSON.stringify(res.data)}`);
412
+ }
227
413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyno181cm.nexsoft/zentao_mcp",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Zentao MCP Server",
5
5
  "repository": {
6
6
  "type": "git",