@sebastienheyd/clickup-mcp 1.9.0 → 1.10.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.
- package/README.md +26 -4
- package/dist/clickup-text.d.ts +1 -0
- package/dist/clickup-text.d.ts.map +1 -1
- package/dist/clickup-text.js +175 -28
- package/dist/shared/attachments.d.ts.map +1 -1
- package/dist/shared/attachments.js +9 -3
- package/dist/shared/comments.d.ts +98 -0
- package/dist/shared/comments.d.ts.map +1 -0
- package/dist/shared/comments.js +158 -0
- package/dist/shared/config.d.ts.map +1 -1
- package/dist/shared/config.js +31 -10
- package/dist/shared/dates.d.ts +25 -0
- package/dist/shared/dates.d.ts.map +1 -0
- package/dist/shared/dates.js +42 -0
- package/dist/shared/utils.d.ts +1 -1
- package/dist/shared/utils.js +4 -4
- package/dist/tools/list-tools.js +9 -9
- package/dist/tools/task-tools.d.ts.map +1 -1
- package/dist/tools/task-tools.js +82 -18
- package/dist/tools/task-write-tools.d.ts.map +1 -1
- package/dist/tools/task-write-tools.js +306 -116
- package/dist/tools/time-tools.d.ts.map +1 -1
- package/dist/tools/time-tools.js +17 -7
- package/package.json +2 -2
|
@@ -6,10 +6,20 @@ const config_1 = require("../shared/config");
|
|
|
6
6
|
const utils_1 = require("../shared/utils");
|
|
7
7
|
const clickup_text_1 = require("../clickup-text");
|
|
8
8
|
const attachments_1 = require("../shared/attachments");
|
|
9
|
+
const comments_1 = require("../shared/comments");
|
|
10
|
+
const dates_1 = require("../shared/dates");
|
|
9
11
|
/**
|
|
10
12
|
* Shared wording for the image support of every markdown field in this file.
|
|
11
13
|
* Kept in one place so the tools stay consistent about what a client may pass.
|
|
12
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* Shared wording for what markdown ClickUp comments can actually render.
|
|
17
|
+
* Kept in one place so addComment and editComment stay consistent.
|
|
18
|
+
*/
|
|
19
|
+
const COMMENT_FORMATTING_HINT = [
|
|
20
|
+
"FORMATTING: Headings, **bold**, *italic*, ~~strikethrough~~, `inline code`, code blocks, links, blockquotes, bullet/numbered/nested lists and checkboxes (- [ ] / - [x]) all render natively.",
|
|
21
|
+
"TABLES ARE NOT SUPPORTED by ClickUp comments - a markdown table is automatically converted to a monospace code block, which is readable but plain. Prefer bold labels or lists over tables when writing comments.",
|
|
22
|
+
].join("\n");
|
|
13
23
|
const IMAGE_SUPPORT_HINT = [
|
|
14
24
|
"IMAGES: Reference images with normal markdown - ``.",
|
|
15
25
|
"This server runs locally, so a local file path is read and uploaded automatically - never inline a screenshot as base64 when a path exists, it costs orders of magnitude more tokens.",
|
|
@@ -93,21 +103,25 @@ const taskPrioritySchema = zod_1.z.enum(["urgent", "high", "normal", "low"]).opt
|
|
|
93
103
|
const taskDueDateSchema = zod_1.z.string().optional().describe("Optional due date as ISO date string (e.g., '2024-10-06T23:59:59+02:00')");
|
|
94
104
|
const taskStartDateSchema = zod_1.z.string().optional().describe("Optional start date as ISO date string (e.g., '2024-10-06T09:00:00+02:00')");
|
|
95
105
|
const taskTimeEstimateSchema = zod_1.z.number().optional().describe("Optional time estimate in hours (will be converted to milliseconds)");
|
|
96
|
-
const taskPointsSchema = zod_1.z.number().nonnegative().optional().describe("Optional sprint points - requires the Sprint Points ClickApp on the space. Valid values depend on the points scale configured in that workspace: ClickUp rejects any other value with 'not a valid points selection'");
|
|
97
106
|
const taskTagsSchema = zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of tag names");
|
|
107
|
+
const taskPointsSchema = zod_1.z.number().nonnegative().optional().describe("Optional sprint points - requires the Sprint Points ClickApp on the space. Valid values depend on the points scale configured in that workspace: ClickUp rejects any other value with 'not a valid points selection'");
|
|
108
|
+
const taskIdSchema = zod_1.z.string().min(1).refine(val => (0, utils_1.isTaskId)(val) || (0, utils_1.isCustomTaskId)(val), {
|
|
109
|
+
message: "Must be an internal task ID (6-16 alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
|
|
110
|
+
});
|
|
98
111
|
const CLEAR_HINT = " - pass null to clear the current value";
|
|
99
112
|
function registerTaskToolsWrite(server, userData) {
|
|
100
113
|
// Workaround: SDK 1.27+ dual Zod v3/v4 type causes TS2589 on server.tool() generics
|
|
101
114
|
const tool = server.tool.bind(server);
|
|
102
115
|
tool("addComment", (() => {
|
|
103
116
|
const descriptionBase = [
|
|
104
|
-
"Adds a comment to a specific task.",
|
|
117
|
+
"Adds a comment to a specific task, or a threaded reply to an existing comment when `parent_comment_id` is set.",
|
|
105
118
|
"LINKING BEST PRACTICES:",
|
|
106
119
|
"- Always reference related tasks using ClickUp URLs (https://app.clickup.com/t/TASK_ID)",
|
|
107
120
|
"- Task URLs become live task references (chip with task name and status), so write them bare - any custom link text on a task URL is replaced by the live task name",
|
|
108
121
|
"- Include task links when mentioning dependencies, related work, or follow-ups",
|
|
109
122
|
"- Link to relevant lists, spaces, or other ClickUp entities when applicable",
|
|
110
123
|
"PROGRESS UPDATES: Include current status, progress information, and next steps.",
|
|
124
|
+
COMMENT_FORMATTING_HINT,
|
|
111
125
|
IMAGE_SUPPORT_HINT,
|
|
112
126
|
"IMAGE LAYOUT: An image inside a numbered list breaks ClickUp's numbering. Write walkthrough steps as bold lines with a blank line before and after the image instead (`**1. Open the login page**`).",
|
|
113
127
|
"If external links are provided, verify they are publicly accessible and incorporate relevant information.",
|
|
@@ -118,32 +132,56 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
118
132
|
}
|
|
119
133
|
return descriptionBase.join("\n");
|
|
120
134
|
})(), {
|
|
121
|
-
task_id:
|
|
122
|
-
message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
|
|
123
|
-
}).describe("The task ID to comment on: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
135
|
+
task_id: taskIdSchema.describe("The task ID to comment on: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
124
136
|
comment: zod_1.z.string().min(1).describe("The comment text to add to the task"),
|
|
137
|
+
parent_comment_id: zod_1.z.string().min(1).optional().describe("Optional: reply inside an existing comment thread instead of posting a new top-level comment. Pass the comment_id of a TOP-LEVEL comment as returned by getTaskById or addComment - ClickUp threads are one level deep, so a reply cannot have replies of its own."),
|
|
125
138
|
}, {
|
|
126
139
|
readOnlyHint: false,
|
|
127
140
|
destructiveHint: false,
|
|
128
141
|
idempotentHint: false,
|
|
129
|
-
}, async ({ task_id, comment }) => {
|
|
142
|
+
}, async ({ task_id, comment, parent_comment_id }) => {
|
|
130
143
|
try {
|
|
131
|
-
// Resolve custom task
|
|
132
|
-
|
|
144
|
+
// Resolve custom task IDs (e.g. "SOI-4422") to internal IDs
|
|
145
|
+
task_id = await (0, utils_1.resolveTaskId)(task_id);
|
|
146
|
+
// The reply endpoint anchors the reply to the parent comment's task and
|
|
147
|
+
// ignores task_id, while images below are uploaded to task_id - so the
|
|
148
|
+
// parent must verifiably be a top-level comment of THIS task before
|
|
149
|
+
// anything is written. This also rejects reply ids (nested replies are
|
|
150
|
+
// not supported by ClickUp's one-level threads).
|
|
151
|
+
if (parent_comment_id) {
|
|
152
|
+
const { comment: parent, exhaustive } = await (0, comments_1.findTopLevelComment)(task_id, parent_comment_id);
|
|
153
|
+
if (!parent && !exhaustive) {
|
|
154
|
+
// The lookup stopped at the page cap: the id may well be a valid but old
|
|
155
|
+
// comment, so do not claim it is invalid.
|
|
156
|
+
throw new Error(`Comment ${parent_comment_id} was not found among the ${comments_1.MAX_COMMENT_PAGES * comments_1.COMMENTS_PER_PAGE} newest top-level comments of task ${task_id}, so no reply was posted. ` +
|
|
157
|
+
`The lookup stops after ${comments_1.MAX_COMMENT_PAGES} pages to protect the API budget and getTaskById shows the same range - ` +
|
|
158
|
+
`reply to a comment listed there, or post a new top-level comment instead.`);
|
|
159
|
+
}
|
|
160
|
+
if (!parent) {
|
|
161
|
+
throw new Error(`Comment ${parent_comment_id} is not a top-level comment of task ${task_id}, so no reply was posted. ` +
|
|
162
|
+
`parent_comment_id must be the comment_id of a top-level comment of this task as returned by getTaskById - ` +
|
|
163
|
+
`a reply's id or a comment of another task cannot be used.`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
133
166
|
// Resolve and upload referenced images first - the fragments need the
|
|
134
167
|
// attachment objects from the upload response, a bare URL renders as an
|
|
135
168
|
// empty tile. Any image problem aborts BEFORE the comment is posted, so
|
|
136
169
|
// the caller can fix the markdown and retry without creating duplicates.
|
|
137
170
|
const abortNotice = "the comment was NOT posted";
|
|
138
171
|
const { markdown, images } = await resolveImagesOrAbort(comment, abortNotice);
|
|
139
|
-
const uploaded = await uploadImagesOrAbort(
|
|
172
|
+
const uploaded = await uploadImagesOrAbort(task_id, images, abortNotice);
|
|
140
173
|
// Convert markdown to ClickUp formatted blocks
|
|
141
174
|
const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(uploaded));
|
|
142
175
|
const requestBody = {
|
|
143
176
|
comment: commentBlocks,
|
|
144
177
|
notify_all: true
|
|
145
178
|
};
|
|
146
|
-
|
|
179
|
+
// A threaded reply goes to the parent comment's reply endpoint; the body
|
|
180
|
+
// is identical to a top-level comment.
|
|
181
|
+
const url = parent_comment_id
|
|
182
|
+
? `https://api.clickup.com/api/v2/comment/${parent_comment_id}/reply`
|
|
183
|
+
: `https://api.clickup.com/api/v2/task/${task_id}/comment`;
|
|
184
|
+
const response = await fetch(url, {
|
|
147
185
|
method: 'POST',
|
|
148
186
|
headers: {
|
|
149
187
|
Authorization: config_1.CONFIG.apiKey,
|
|
@@ -153,7 +191,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
153
191
|
});
|
|
154
192
|
if (!response.ok) {
|
|
155
193
|
const errorData = await response.json().catch(() => ({}));
|
|
156
|
-
throw new Error(`
|
|
194
|
+
throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
|
|
157
195
|
}
|
|
158
196
|
const commentData = await response.json();
|
|
159
197
|
return {
|
|
@@ -161,9 +199,13 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
161
199
|
{
|
|
162
200
|
type: "text",
|
|
163
201
|
text: [
|
|
164
|
-
`Comment added successfully!`,
|
|
202
|
+
parent_comment_id ? `Reply added successfully!` : `Comment added successfully!`,
|
|
165
203
|
`comment_id: ${commentData.id || 'N/A'}`,
|
|
166
|
-
|
|
204
|
+
...(parent_comment_id ? [
|
|
205
|
+
`parent_comment_id: ${parent_comment_id}`,
|
|
206
|
+
`note: ClickUp threads are one level deep - a reply's comment_id cannot be used as parent_comment_id or with editComment.`,
|
|
207
|
+
] : []),
|
|
208
|
+
`task_id: ${task_id}`,
|
|
167
209
|
`comment: ${summarizeMarkdownForEcho(comment)}`,
|
|
168
210
|
`date: ${timestampToIso(commentData.date || Date.now())}`,
|
|
169
211
|
`user: ${commentData.user?.username || 'Current user'}`,
|
|
@@ -192,8 +234,9 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
192
234
|
`GUARDRAILS: only comments written by the API token's own user can be edited, and only within ${config_1.CONFIG.commentEditWindowHours} hours of their creation. Older comments and other people's comments must be answered with a new comment via addComment.`,
|
|
193
235
|
"ClickUp shows no 'edited' marker, so people who already read the comment will not notice the change - for anything that changes meaning after a discussion has started, prefer a follow-up comment.",
|
|
194
236
|
"Editing does not reset the creation date, so the edit window does not get extended by editing.",
|
|
195
|
-
|
|
196
|
-
"IMAGES ON EDIT:
|
|
237
|
+
COMMENT_FORMATTING_HINT,
|
|
238
|
+
"IMAGES ON EDIT: an edit can only KEEP the images already in this comment. Reading the comment (getTaskById) returns them as markdown, and passing that markdown back keeps them; an image whose markdown you drop disappears.",
|
|
239
|
+
"ClickUp refuses to add any other image to an existing comment - a new file, a data URI, a web URL, or an image of another comment or of the task description - so such a reference is rejected before anything is uploaded. To share a new image, post a new comment with addComment.",
|
|
197
240
|
"Task URLs (https://app.clickup.com/t/TASK_ID) become live task references, and existing references are read back as such URLs - passing the text back keeps them.",
|
|
198
241
|
];
|
|
199
242
|
if (config_1.CONFIG.primaryLanguageHint && config_1.CONFIG.primaryLanguageHint.toLowerCase() !== 'en') {
|
|
@@ -201,10 +244,8 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
201
244
|
}
|
|
202
245
|
return descriptionBase.join("\n");
|
|
203
246
|
})(), {
|
|
204
|
-
task_id:
|
|
205
|
-
|
|
206
|
-
}).describe("The ID of the task the comment belongs to - needed to locate the comment and to upload images. Internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
207
|
-
comment_id: zod_1.z.string().min(1).describe("The ID of the comment to edit, as returned by addComment or getTaskById"),
|
|
247
|
+
task_id: taskIdSchema.describe("The ID of the task the comment belongs to - needed to locate the comment and to upload images: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
248
|
+
comment_id: zod_1.z.string().min(1).describe("The ID of the comment to edit, as returned by addComment or getTaskById. Only top-level comments can be edited - replies inside a thread cannot."),
|
|
208
249
|
comment: zod_1.z.string().min(1).describe("The new comment text, replacing the previous text completely"),
|
|
209
250
|
}, {
|
|
210
251
|
readOnlyHint: false,
|
|
@@ -212,21 +253,21 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
212
253
|
idempotentHint: true,
|
|
213
254
|
}, async ({ task_id, comment_id, comment }) => {
|
|
214
255
|
try {
|
|
215
|
-
// Resolve custom task
|
|
216
|
-
|
|
256
|
+
// Resolve custom task IDs (e.g. "SOI-4422") to internal IDs
|
|
257
|
+
task_id = await (0, utils_1.resolveTaskId)(task_id);
|
|
217
258
|
const [existing, userData] = await Promise.all([
|
|
218
|
-
findTaskComment(
|
|
259
|
+
findTaskComment(task_id, comment_id),
|
|
219
260
|
(0, utils_1.getCurrentUser)(),
|
|
220
261
|
]);
|
|
221
262
|
assertCommentIsEditable(existing, userData.user.id);
|
|
222
|
-
// Same
|
|
223
|
-
// accepted by PUT too, so formatting
|
|
224
|
-
//
|
|
225
|
-
//
|
|
263
|
+
// Same converter as addComment - the undocumented rich `comment` array is
|
|
264
|
+
// accepted by PUT too, so formatting survives an edit. Images are different:
|
|
265
|
+
// ClickUp only lets an edit keep the comment's OWN images, so they are
|
|
266
|
+
// matched against the stored fragments and nothing is ever uploaded here.
|
|
226
267
|
const abortNotice = "the comment was NOT changed";
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(
|
|
268
|
+
const markdown = (0, clickup_text_1.normalizeImageDestinations)(comment);
|
|
269
|
+
const keptImages = keepOwnCommentImages(existing, markdown, abortNotice);
|
|
270
|
+
const commentBlocks = (0, clickup_text_1.convertMarkdownToClickUpBlocks)(markdown, (0, attachments_1.toAttachmentMap)(keptImages));
|
|
230
271
|
// Only `comment` is sent: sending `comment_text` alongside it appends that
|
|
231
272
|
// string to the blocks instead of being ignored.
|
|
232
273
|
const response = await fetch(`https://api.clickup.com/api/v2/comment/${comment_id}`, {
|
|
@@ -239,7 +280,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
239
280
|
});
|
|
240
281
|
if (!response.ok) {
|
|
241
282
|
const errorData = await response.json().catch(() => ({}));
|
|
242
|
-
throw new Error(`
|
|
283
|
+
throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
|
|
243
284
|
}
|
|
244
285
|
return {
|
|
245
286
|
content: [
|
|
@@ -248,12 +289,15 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
248
289
|
text: [
|
|
249
290
|
`Comment edited successfully!`,
|
|
250
291
|
`comment_id: ${comment_id}`,
|
|
251
|
-
`task_id: ${
|
|
252
|
-
`task_url: https://app.clickup.com/t/${
|
|
292
|
+
`task_id: ${task_id}`,
|
|
293
|
+
`task_url: https://app.clickup.com/t/${task_id}`,
|
|
253
294
|
`created: ${timestampToIso(existing.date)} (unchanged by the edit)`,
|
|
254
295
|
`previous_text: ${existing.comment_text || '(no plain text available)'}`,
|
|
255
296
|
`new_comment: ${summarizeMarkdownForEcho(comment)}`,
|
|
256
|
-
...
|
|
297
|
+
...(keptImages.length > 0 ? [
|
|
298
|
+
`images_kept: ${keptImages.length}`,
|
|
299
|
+
...keptImages.map((k) => ` - ${k.attachment.name} (${k.attachment.url})`),
|
|
300
|
+
] : []),
|
|
257
301
|
].join('\n')
|
|
258
302
|
}
|
|
259
303
|
],
|
|
@@ -275,9 +319,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
275
319
|
"Deletes a task comment. This cannot be undone - the deleted text is echoed back so it can be re-posted with addComment if needed.",
|
|
276
320
|
`GUARDRAILS: same as editComment - only comments written by the API token's own user can be deleted, and only within ${config_1.CONFIG.commentEditWindowHours} hours of their creation. Replies inside a thread cannot be deleted.`,
|
|
277
321
|
].join("\n"), {
|
|
278
|
-
task_id:
|
|
279
|
-
message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
|
|
280
|
-
}).describe("The ID of the task the comment belongs to - needed to locate the comment. Internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
322
|
+
task_id: taskIdSchema.describe("The ID of the task the comment belongs to - needed to locate the comment. Internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
281
323
|
comment_id: zod_1.z.string().min(1).describe("The ID of the comment to delete, as returned by addComment or getTaskById"),
|
|
282
324
|
}, {
|
|
283
325
|
readOnlyHint: false,
|
|
@@ -297,7 +339,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
297
339
|
});
|
|
298
340
|
if (!response.ok) {
|
|
299
341
|
const errorData = await response.json().catch(() => ({}));
|
|
300
|
-
throw new Error(`
|
|
342
|
+
throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
|
|
301
343
|
}
|
|
302
344
|
return {
|
|
303
345
|
content: [
|
|
@@ -332,11 +374,12 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
332
374
|
"Updates various aspects of an existing task including dependencies and relationships.",
|
|
333
375
|
"ALWAYS include the task URL (https://app.clickup.com/t/TASK_ID) when updating or referencing tasks.",
|
|
334
376
|
"Use getListInfo first to see valid status options.",
|
|
335
|
-
"
|
|
377
|
+
"DESCRIPTIONS: `append_description` adds a dated section below the existing text; `description` REPLACES the whole text (use it to restructure, shorten or correct a description). Pass only one of them.",
|
|
378
|
+
"Before replacing, read the current description via getTaskById and carry over everything that is still needed - ClickUp keeps a description history, so a bad replacement can be restored in the UI (edits within the same minute merge into one version), but the MCP cannot undo it.",
|
|
336
379
|
"STATUS UPDATES: Use the `addComment` tool for progress reports, work logs, and status updates rather than the task description.",
|
|
337
380
|
IMAGE_SUPPORT_HINT,
|
|
338
381
|
"Task descriptions should contain requirements, specifications, and core task information.",
|
|
339
|
-
"LINKING IN DESCRIPTIONS:
|
|
382
|
+
"LINKING IN DESCRIPTIONS: Include links to related tasks, lists, or external resources (format: https://app.clickup.com/t/TASK_ID).",
|
|
340
383
|
"IMPORTANT: When updating tasks (especially when booking time or adding progress), ensure the status makes sense for the work being done - tasks in 'backlog' or 'closed' states usually shouldn't have active work.",
|
|
341
384
|
"Suggest appropriate status transitions and always provide the clickable task URL in responses."
|
|
342
385
|
];
|
|
@@ -345,12 +388,10 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
345
388
|
}
|
|
346
389
|
return descriptionBase.join("\n");
|
|
347
390
|
})(), {
|
|
348
|
-
task_id:
|
|
349
|
-
message: "Must be an internal task ID (6+ alphanumeric characters) or a custom task ID (e.g. SOI-4422)"
|
|
350
|
-
}).describe("The task ID to update: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
391
|
+
task_id: taskIdSchema.describe("The task ID to update: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
351
392
|
name: taskNameSchema.optional(),
|
|
352
|
-
|
|
353
|
-
|
|
393
|
+
description: zod_1.z.string().optional().describe("Optional markdown that REPLACES the entire task description. Read the current description first (getTaskById) and repeat everything worth keeping - nothing is merged. Cannot be combined with append_description."),
|
|
394
|
+
append_description: zod_1.z.string().optional().describe("Optional markdown content to APPEND below the existing task description as a dated `**Edit (YYYY-MM-DD):**` section (existing content is preserved). Cannot be combined with description."),
|
|
354
395
|
status: zod_1.z.string().optional().describe("Optional new status name - use getListInfo to see valid options"),
|
|
355
396
|
priority: taskPrioritySchema,
|
|
356
397
|
due_date: taskDueDateSchema.nullable().describe(taskDueDateSchema.description + CLEAR_HINT),
|
|
@@ -358,7 +399,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
358
399
|
time_estimate: taskTimeEstimateSchema.nullable().describe(taskTimeEstimateSchema.description + CLEAR_HINT),
|
|
359
400
|
points: taskPointsSchema.nullable().describe(taskPointsSchema.description + CLEAR_HINT),
|
|
360
401
|
tags: taskTagsSchema.describe("Optional array of tag names (will replace existing tags)"),
|
|
361
|
-
parent_task_id:
|
|
402
|
+
parent_task_id: taskIdSchema.optional().describe("Optional parent task ID to move this task under, as a subtask: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
362
403
|
assignees: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of user IDs to add as assignees - existing assignees are kept"),
|
|
363
404
|
remove_assignees: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of user IDs to unassign from the task. Combine with assignees to replace one assignee by another."),
|
|
364
405
|
waiting_on: zod_1.z.array(zod_1.z.string()).optional().describe("Optional array of task IDs that this task should wait on (will replace existing waiting_on relationships)"),
|
|
@@ -369,18 +410,37 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
369
410
|
destructiveHint: true,
|
|
370
411
|
idempotentHint: false,
|
|
371
412
|
openWorldHint: true
|
|
372
|
-
}, async ({ task_id, name,
|
|
413
|
+
}, async ({ task_id, name, description, append_description, status, priority, due_date, start_date, time_estimate, points, tags, parent_task_id, assignees, remove_assignees, blocking, waiting_on, linked_tasks }) => {
|
|
373
414
|
try {
|
|
374
|
-
|
|
415
|
+
// Argument errors are checked before anything hits the network: resolving a
|
|
416
|
+
// custom task ID costs an API call that would be wasted on a rejected request.
|
|
417
|
+
if (description !== undefined && append_description !== undefined) {
|
|
375
418
|
return {
|
|
376
|
-
content: [{
|
|
419
|
+
content: [{
|
|
420
|
+
type: "text",
|
|
421
|
+
text: "Error: `description` (replace) and `append_description` (append) are mutually exclusive - pass only one of them. The task was NOT updated."
|
|
422
|
+
}],
|
|
377
423
|
};
|
|
378
424
|
}
|
|
379
|
-
//
|
|
380
|
-
|
|
425
|
+
// A malformed date must be refused before anything is written: it would
|
|
426
|
+
// otherwise be sent as null and clear the date (see parseDateInput).
|
|
427
|
+
assertValidTaskDates({ due_date, start_date });
|
|
428
|
+
// Resolve custom task IDs (e.g. "SOI-4422") to internal IDs
|
|
429
|
+
task_id = await (0, utils_1.resolveTaskId)(task_id);
|
|
430
|
+
// Current dependencies and links come back from ClickUp with internal IDs,
|
|
431
|
+
// so the requested lists must use them too: an unresolved "SOI-4422" would
|
|
432
|
+
// not match its own internal ID, and the existing relation would be removed
|
|
433
|
+
// as "no longer requested" while a duplicate is added. Resolved up front, so
|
|
434
|
+
// an unknown custom ID aborts the update before anything is written.
|
|
435
|
+
[blocking, waiting_on, linked_tasks] = await Promise.all([blocking, waiting_on, linked_tasks].map(resolveTaskIdList));
|
|
436
|
+
// ClickUp's `parent` field only takes an internal ID: a custom ID gets a bare
|
|
437
|
+
// 500 Internal Server Error (verified live), with nothing to tell what is wrong.
|
|
438
|
+
if (parent_task_id !== undefined) {
|
|
439
|
+
parent_task_id = await (0, utils_1.resolveTaskId)(parent_task_id);
|
|
440
|
+
}
|
|
381
441
|
const userData = await (0, utils_1.getCurrentUser)();
|
|
382
442
|
// Get task details including current markdown description
|
|
383
|
-
const taskResponse = await fetch(`https://api.clickup.com/api/v2/task/${
|
|
443
|
+
const taskResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}?include_markdown_description=true`, {
|
|
384
444
|
headers: { Authorization: config_1.CONFIG.apiKey },
|
|
385
445
|
});
|
|
386
446
|
if (!taskResponse.ok) {
|
|
@@ -390,15 +450,16 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
390
450
|
// Resolve and upload description images FIRST - an image problem must
|
|
391
451
|
// abort before dependencies, tags or the task itself are touched, so the
|
|
392
452
|
// caller can fix the markdown and retry the whole call cleanly.
|
|
393
|
-
//
|
|
394
|
-
//
|
|
453
|
+
// `description` replaces, `append_description` appends; both go through
|
|
454
|
+
// the same image pipeline. An empty `description` is a valid request to
|
|
455
|
+
// clear the description, so check for undefined rather than truthiness.
|
|
456
|
+
const descriptionInput = description !== undefined ? description : append_description;
|
|
395
457
|
let preparedDescription;
|
|
396
458
|
let uploadedImages = [];
|
|
397
|
-
|
|
398
|
-
if (descriptionInput) {
|
|
459
|
+
if (descriptionInput !== undefined) {
|
|
399
460
|
const abortNotice = "the task was NOT updated";
|
|
400
461
|
const prepared = await resolveImagesOrAbort(descriptionInput, abortNotice);
|
|
401
|
-
uploadedImages = await uploadImagesOrAbort(
|
|
462
|
+
uploadedImages = await uploadImagesOrAbort(task_id, prepared.images, abortNotice);
|
|
402
463
|
// Descriptions render plain markdown, so no image fragments are involved
|
|
403
464
|
// here - the local paths are simply swapped for the CDN URLs.
|
|
404
465
|
preparedDescription = (0, clickup_text_1.rewriteMarkdownImageUrls)(prepared.markdown, (0, attachments_1.toAttachmentMap)(uploadedImages));
|
|
@@ -406,7 +467,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
406
467
|
// Handle dependencies separately since they need individual API calls
|
|
407
468
|
let dependencyUpdateResults = [];
|
|
408
469
|
if (blocking !== undefined || waiting_on !== undefined || linked_tasks !== undefined) {
|
|
409
|
-
const dependencyResults = await updateTaskDependencies(
|
|
470
|
+
const dependencyResults = await updateTaskDependencies(task_id, taskData, { blocking, waiting_on, linked_tasks });
|
|
410
471
|
dependencyUpdateResults = dependencyResults;
|
|
411
472
|
}
|
|
412
473
|
// Handle tags separately since they need individual API calls
|
|
@@ -419,7 +480,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
419
480
|
// Add new tags
|
|
420
481
|
for (const tagName of tagsToAdd) {
|
|
421
482
|
try {
|
|
422
|
-
const addTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${
|
|
483
|
+
const addTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}/tag/${encodeURIComponent(tagName)}`, {
|
|
423
484
|
method: 'POST',
|
|
424
485
|
headers: { Authorization: config_1.CONFIG.apiKey }
|
|
425
486
|
});
|
|
@@ -436,7 +497,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
436
497
|
// Remove old tags
|
|
437
498
|
for (const tagName of tagsToRemove) {
|
|
438
499
|
try {
|
|
439
|
-
const removeTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${
|
|
500
|
+
const removeTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}/tag/${encodeURIComponent(tagName)}`, {
|
|
440
501
|
method: 'DELETE',
|
|
441
502
|
headers: { Authorization: config_1.CONFIG.apiKey }
|
|
442
503
|
});
|
|
@@ -451,14 +512,15 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
451
512
|
}
|
|
452
513
|
}
|
|
453
514
|
}
|
|
454
|
-
//
|
|
455
|
-
//
|
|
515
|
+
// Build the description to write: a full replacement, or the existing
|
|
516
|
+
// text plus a dated append section.
|
|
456
517
|
let finalDescription;
|
|
457
|
-
|
|
458
|
-
|
|
518
|
+
if (description !== undefined) {
|
|
519
|
+
// An empty string clears the description (the image pipeline skips empty input)
|
|
459
520
|
finalDescription = preparedDescription ?? "";
|
|
460
521
|
}
|
|
461
522
|
else if (preparedDescription !== undefined) {
|
|
523
|
+
const currentDescription = taskData.markdown_description || "";
|
|
462
524
|
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
463
525
|
const separator = currentDescription.trim() ? "\n\n---\n" : "";
|
|
464
526
|
finalDescription = currentDescription + separator + `**Edit (${timestamp}):** ${preparedDescription}`;
|
|
@@ -489,7 +551,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
489
551
|
// Update the task (if there are non-tag updates)
|
|
490
552
|
let updatedTask = taskData;
|
|
491
553
|
if (Object.keys(updateBody).length > 0) {
|
|
492
|
-
const updateResponse = await fetch(`https://api.clickup.com/api/v2/task/${
|
|
554
|
+
const updateResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}`, {
|
|
493
555
|
method: 'PUT',
|
|
494
556
|
headers: {
|
|
495
557
|
Authorization: config_1.CONFIG.apiKey,
|
|
@@ -499,13 +561,13 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
499
561
|
});
|
|
500
562
|
if (!updateResponse.ok) {
|
|
501
563
|
const errorData = await updateResponse.json().catch(() => ({}));
|
|
502
|
-
throw new Error(`
|
|
564
|
+
throw new Error(`ClickUp API ${updateResponse.status} ${updateResponse.statusText} - ${JSON.stringify(errorData)}`);
|
|
503
565
|
}
|
|
504
566
|
updatedTask = await updateResponse.json();
|
|
505
567
|
}
|
|
506
568
|
// If only tags or dependencies were updated, fetch the task again to get the updated state
|
|
507
569
|
if ((tags !== undefined || blocking !== undefined || waiting_on !== undefined || linked_tasks !== undefined) && Object.keys(updateBody).length === 0) {
|
|
508
|
-
const refreshResponse = await fetch(`https://api.clickup.com/api/v2/task/${
|
|
570
|
+
const refreshResponse = await fetch(`https://api.clickup.com/api/v2/task/${task_id}`, {
|
|
509
571
|
headers: { Authorization: config_1.CONFIG.apiKey },
|
|
510
572
|
});
|
|
511
573
|
if (refreshResponse.ok) {
|
|
@@ -513,11 +575,16 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
513
575
|
}
|
|
514
576
|
}
|
|
515
577
|
const responseLines = formatTaskResponse(updatedTask, 'updated', {
|
|
516
|
-
name, append_description, status, priority, due_date, start_date, time_estimate, points, tags, parent_task_id, assignees, remove_assignees, blocking, waiting_on, linked_tasks
|
|
578
|
+
name, description, append_description, status, priority, due_date, start_date, time_estimate, points, tags, parent_task_id, assignees, remove_assignees, blocking, waiting_on, linked_tasks
|
|
517
579
|
}, userData);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
responseLines.push(`
|
|
580
|
+
if (description !== undefined) {
|
|
581
|
+
const previousDescription = taskData.markdown_description || "";
|
|
582
|
+
responseLines.push(`description: replaced (previous ${previousDescription.length} chars -> ${finalDescription?.length ?? 0} chars; the old version stays restorable via ClickUp's description history)`);
|
|
583
|
+
// Echo what was overwritten so a wrong replacement can be undone with another call
|
|
584
|
+
responseLines.push(`previous_description: ${previousDescription.trim() ? previousDescription : '(empty)'}`);
|
|
585
|
+
}
|
|
586
|
+
else if (append_description !== undefined) {
|
|
587
|
+
responseLines.push('description: appended as dated edit section');
|
|
521
588
|
}
|
|
522
589
|
// Add dependency update results if any
|
|
523
590
|
if (dependencyUpdateResults.length > 0) {
|
|
@@ -577,7 +644,7 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
577
644
|
time_estimate: taskTimeEstimateSchema,
|
|
578
645
|
points: taskPointsSchema,
|
|
579
646
|
tags: taskTagsSchema,
|
|
580
|
-
parent_task_id:
|
|
647
|
+
parent_task_id: taskIdSchema.optional().describe("Optional parent task ID to create this as a subtask: internal ID (e.g. \"869c4za0g\") or custom ID (e.g. \"SOI-4422\")"),
|
|
581
648
|
assignees: zod_1.z.array(zod_1.z.string()).optional().describe(createAssigneeDescription(userData))
|
|
582
649
|
}, {
|
|
583
650
|
readOnlyHint: false,
|
|
@@ -591,6 +658,12 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
591
658
|
// behind. Uploading has to wait until the task exists, though - ClickUp
|
|
592
659
|
// attachments always belong to a task.
|
|
593
660
|
const { markdown: normalizedDescription, images } = await resolveImagesOrAbort(description, "the task was NOT created");
|
|
661
|
+
// ClickUp's `parent` field only takes an internal ID: a custom ID gets a bare
|
|
662
|
+
// 500 Internal Server Error (verified live). Resolved before the task exists,
|
|
663
|
+
// so an unknown parent leaves nothing behind.
|
|
664
|
+
if (parent_task_id !== undefined) {
|
|
665
|
+
parent_task_id = await (0, utils_1.resolveTaskId)(parent_task_id);
|
|
666
|
+
}
|
|
594
667
|
const userData = await (0, utils_1.getCurrentUser)();
|
|
595
668
|
const currentUserId = userData.user.id;
|
|
596
669
|
const requestBody = buildTaskRequestBody({
|
|
@@ -610,30 +683,29 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
610
683
|
});
|
|
611
684
|
if (!response.ok) {
|
|
612
685
|
const errorData = await response.json().catch(() => ({}));
|
|
613
|
-
throw new Error(`
|
|
686
|
+
throw new Error(`ClickUp API ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
|
|
614
687
|
}
|
|
615
|
-
|
|
616
|
-
//
|
|
617
|
-
|
|
688
|
+
const createdTask = await response.json();
|
|
689
|
+
// Tags are omitted from the create body by buildTaskRequestBody because they
|
|
690
|
+
// need the dedicated tag endpoints, so apply them here - the same way
|
|
691
|
+
// updateTask does - otherwise the requested tags are silently dropped.
|
|
692
|
+
const tagCreateResults = [];
|
|
693
|
+
if (tags !== undefined && tags.length > 0) {
|
|
618
694
|
for (const tagName of tags) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
695
|
+
try {
|
|
696
|
+
const addTagResponse = await fetch(`https://api.clickup.com/api/v2/task/${createdTask.id}/tag/${encodeURIComponent(tagName)}`, {
|
|
697
|
+
method: 'POST',
|
|
698
|
+
headers: { Authorization: config_1.CONFIG.apiKey }
|
|
699
|
+
});
|
|
700
|
+
if (!addTagResponse.ok) {
|
|
701
|
+
console.error(`Failed to add tag "${tagName}": ${addTagResponse.status}`);
|
|
702
|
+
tagCreateResults.push(`Failed to add tag: ${tagName}`);
|
|
625
703
|
}
|
|
626
|
-
});
|
|
627
|
-
if (!tagResponse.ok) {
|
|
628
|
-
console.error(`Error adding tag "${tagName}" to task ${createdTask.id}: ${tagResponse.status}`);
|
|
629
704
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
});
|
|
635
|
-
if (refreshResponse.ok) {
|
|
636
|
-
createdTask = await refreshResponse.json();
|
|
705
|
+
catch (error) {
|
|
706
|
+
console.error(`Error adding tag "${tagName}":`, error);
|
|
707
|
+
tagCreateResults.push(`Error adding tag: ${tagName}`);
|
|
708
|
+
}
|
|
637
709
|
}
|
|
638
710
|
}
|
|
639
711
|
// Images can only be attached once the task exists, so the description is
|
|
@@ -671,6 +743,9 @@ function registerTaskToolsWrite(server, userData) {
|
|
|
671
743
|
}, userData);
|
|
672
744
|
responseLines.push(...formatAttachedImages(uploaded));
|
|
673
745
|
responseLines.push(...imageWarnings);
|
|
746
|
+
if (tagCreateResults.length > 0) {
|
|
747
|
+
responseLines.push('tag_warnings: ' + tagCreateResults.join('; '));
|
|
748
|
+
}
|
|
674
749
|
return {
|
|
675
750
|
content: [
|
|
676
751
|
{
|
|
@@ -707,9 +782,16 @@ function convertPriorityToNumber(priority) {
|
|
|
707
782
|
default: return 3;
|
|
708
783
|
}
|
|
709
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* The task payload carries the priority NAME (`priority: { id: "4", priority: "low" }`),
|
|
787
|
+
* so a numeric-only lookup turned every real priority into "unknown". Accept both.
|
|
788
|
+
*/
|
|
710
789
|
function convertPriorityToString(priority) {
|
|
790
|
+
if (typeof priority === 'string' && ['urgent', 'high', 'normal', 'low'].includes(priority.toLowerCase())) {
|
|
791
|
+
return priority.toLowerCase();
|
|
792
|
+
}
|
|
711
793
|
const priorityMap = { 1: 'urgent', 2: 'high', 3: 'normal', 4: 'low' };
|
|
712
|
-
return priorityMap[priority] || 'unknown';
|
|
794
|
+
return priorityMap[Number(priority)] || 'unknown';
|
|
713
795
|
}
|
|
714
796
|
function formatTimeEstimate(hours) {
|
|
715
797
|
const displayHours = Math.floor(hours);
|
|
@@ -717,24 +799,67 @@ function formatTimeEstimate(hours) {
|
|
|
717
799
|
return displayHours > 0 ? `${displayHours}h ${displayMinutes}m` : `${displayMinutes}m`;
|
|
718
800
|
}
|
|
719
801
|
/**
|
|
720
|
-
*
|
|
721
|
-
*
|
|
802
|
+
* Match the image references of an edited comment against the images the comment
|
|
803
|
+
* already contains, and return the stored attachment object for each of them.
|
|
804
|
+
*
|
|
805
|
+
* Verified live on `PUT /comment/{id}`: an image fragment is only accepted when it
|
|
806
|
+
* carries the real attachment of an image that is ALREADY in that comment. Any
|
|
807
|
+
* other attachment - a fresh upload, the task description's image, an image of
|
|
808
|
+
* another comment - is refused with 401 ACCESS_610, and a fragment whose id is not
|
|
809
|
+
* a real attachment id fails with 404 ACCESS_028. Reusing the stored object (real
|
|
810
|
+
* id, dimensions included) is therefore the only working way to keep an image, and
|
|
811
|
+
* anything else is rejected here, before a request is made - uploading first would
|
|
812
|
+
* only leave an orphan attachment on the task behind the failed edit.
|
|
722
813
|
*/
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
814
|
+
function keepOwnCommentImages(existing, markdown, abortNotice) {
|
|
815
|
+
const sources = (0, clickup_text_1.collectMarkdownImageSources)(markdown);
|
|
816
|
+
if (sources.length === 0) {
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
const ownImages = new Map();
|
|
820
|
+
for (const fragment of existing.comment ?? []) {
|
|
821
|
+
if (fragment?.type === "image" && fragment.image?.url) {
|
|
822
|
+
ownImages.set(normalizeUrl(fragment.image.url), fragment.image);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const kept = [];
|
|
826
|
+
const refused = [];
|
|
827
|
+
const seen = new Set();
|
|
828
|
+
for (const { src } of sources) {
|
|
829
|
+
if (seen.has(src)) {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
seen.add(src);
|
|
833
|
+
const stored = ownImages.get(normalizeUrl(src));
|
|
834
|
+
if (stored) {
|
|
835
|
+
kept.push({ src, attachment: stored });
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
refused.push(src);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (refused.length > 0) {
|
|
842
|
+
throw new Error([
|
|
843
|
+
`${refused.length} image reference(s) are not images of this comment, so ${abortNotice}:`,
|
|
844
|
+
...refused.map((src) => ` - ${summarizeImageSource(src)}`),
|
|
845
|
+
`ClickUp only lets an edit keep the images already in the comment - it refuses to add any other image.`,
|
|
846
|
+
`Remove these references, or post the new image in a new comment with addComment.`,
|
|
847
|
+
].join("\n"));
|
|
735
848
|
}
|
|
736
|
-
|
|
737
|
-
|
|
849
|
+
return kept;
|
|
850
|
+
}
|
|
851
|
+
/** Compare attachment URLs regardless of percent-encoding of the filename */
|
|
852
|
+
function normalizeUrl(url) {
|
|
853
|
+
try {
|
|
854
|
+
return decodeURI(url.trim());
|
|
855
|
+
}
|
|
856
|
+
catch {
|
|
857
|
+
return url.trim();
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/** Shorten a data URI so an error message never repeats a base64 payload */
|
|
861
|
+
function summarizeImageSource(src) {
|
|
862
|
+
return src.startsWith("data:") ? `${src.slice(0, 40)}... (data URI)` : src;
|
|
738
863
|
}
|
|
739
864
|
/**
|
|
740
865
|
* Load a single comment of a task.
|
|
@@ -757,8 +882,8 @@ async function findTaskComment(taskId, commentId) {
|
|
|
757
882
|
let checked = 0;
|
|
758
883
|
let pages = 0;
|
|
759
884
|
let sawThreadedReplies = false;
|
|
760
|
-
while (pages < MAX_COMMENT_PAGES) {
|
|
761
|
-
const page = await fetchCommentPage(taskId, cursor);
|
|
885
|
+
while (pages < comments_1.MAX_COMMENT_PAGES) {
|
|
886
|
+
const page = await (0, comments_1.fetchCommentPage)(taskId, cursor);
|
|
762
887
|
pages++;
|
|
763
888
|
if (page.length === 0) {
|
|
764
889
|
break;
|
|
@@ -769,6 +894,10 @@ async function findTaskComment(taskId, commentId) {
|
|
|
769
894
|
}
|
|
770
895
|
checked += page.length;
|
|
771
896
|
sawThreadedReplies || (sawThreadedReplies = page.some((entry) => (entry.reply_count ?? 0) > 0));
|
|
897
|
+
// A short page is the last one - requesting the next would only return [].
|
|
898
|
+
if (page.length < comments_1.COMMENTS_PER_PAGE) {
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
772
901
|
// Comments come back newest first, so once a page runs past the edit window
|
|
773
902
|
// there is nothing editable further back.
|
|
774
903
|
const oldest = page[page.length - 1];
|
|
@@ -822,6 +951,35 @@ function timestampToIso(timestamp) {
|
|
|
822
951
|
const timezoneOffset = sign + String(offsetHours).padStart(2, '0') + ':' + String(offsetMinutes).padStart(2, '0');
|
|
823
952
|
return `${year}-${month}-${day}T${hours}:${minutes}${timezoneOffset}`;
|
|
824
953
|
}
|
|
954
|
+
/**
|
|
955
|
+
* Convert a due/start date parameter for the task API.
|
|
956
|
+
*
|
|
957
|
+
* ClickUp drops the time of day unless `due_date_time`/`start_date_time` is true
|
|
958
|
+
* (verified live: 18:00 came back as 04:00, its all-day placeholder), so report
|
|
959
|
+
* whether the caller gave a time. A bare date (YYYY-MM-DD) is an all-day value at
|
|
960
|
+
* local midnight. Invalid or impossible dates throw instead of being sent as null.
|
|
961
|
+
*/
|
|
962
|
+
function parseTaskDate(value, field) {
|
|
963
|
+
return (0, dates_1.parseDateInput)(value, field, "start");
|
|
964
|
+
}
|
|
965
|
+
/** Validate due/start dates up front so a bad value never reaches a write */
|
|
966
|
+
function assertValidTaskDates(dates) {
|
|
967
|
+
for (const field of ["due_date", "start_date"]) {
|
|
968
|
+
const value = dates[field];
|
|
969
|
+
if (typeof value === "string") {
|
|
970
|
+
parseTaskDate(value, field);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/** Resolve every ID of a relation list to an internal ID, without duplicates */
|
|
975
|
+
async function resolveTaskIdList(ids) {
|
|
976
|
+
if (ids === undefined) {
|
|
977
|
+
return undefined;
|
|
978
|
+
}
|
|
979
|
+
// Deduplicate before resolving too: each custom ID costs one API call
|
|
980
|
+
const resolved = await Promise.all([...new Set(ids)].map((id) => (0, utils_1.resolveTaskId)(id)));
|
|
981
|
+
return [...new Set(resolved)];
|
|
982
|
+
}
|
|
825
983
|
function buildTaskRequestBody(params, currentUserId) {
|
|
826
984
|
const requestBody = {};
|
|
827
985
|
if (params.name !== undefined) {
|
|
@@ -836,10 +994,24 @@ function buildTaskRequestBody(params, currentUserId) {
|
|
|
836
994
|
// null clears the value - it must be sent as is, new Date(null) would set 1970-01-01.
|
|
837
995
|
// time_estimate is the exception: ClickUp accepts null but silently keeps the estimate, only 0 clears it.
|
|
838
996
|
if (params.due_date !== undefined) {
|
|
839
|
-
|
|
997
|
+
if (params.due_date === null) {
|
|
998
|
+
requestBody.due_date = null;
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
const due = parseTaskDate(params.due_date, 'due_date');
|
|
1002
|
+
requestBody.due_date = due.timestamp;
|
|
1003
|
+
requestBody.due_date_time = due.hasTime;
|
|
1004
|
+
}
|
|
840
1005
|
}
|
|
841
1006
|
if (params.start_date !== undefined) {
|
|
842
|
-
|
|
1007
|
+
if (params.start_date === null) {
|
|
1008
|
+
requestBody.start_date = null;
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
const start = parseTaskDate(params.start_date, 'start_date');
|
|
1012
|
+
requestBody.start_date = start.timestamp;
|
|
1013
|
+
requestBody.start_date_time = start.hasTime;
|
|
1014
|
+
}
|
|
843
1015
|
}
|
|
844
1016
|
if (params.time_estimate !== undefined) {
|
|
845
1017
|
requestBody.time_estimate = params.time_estimate === null ? 0 : Math.round(params.time_estimate * 60 * 60 * 1000);
|
|
@@ -863,10 +1035,28 @@ function buildTaskRequestBody(params, currentUserId) {
|
|
|
863
1035
|
// Helper function to manage task dependencies
|
|
864
1036
|
async function updateTaskDependencies(taskId, taskData, dependencies) {
|
|
865
1037
|
const errors = [];
|
|
866
|
-
// Get current dependencies
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1038
|
+
// Get current dependencies. The task payload has no `blocking`/`waiting_on`
|
|
1039
|
+
// arrays (verified live): both directions live in one flat `dependencies` array
|
|
1040
|
+
// of `{ task_id, depends_on }` records, and this task can sit on either side.
|
|
1041
|
+
// Reading the missing fields made every current dependency look absent, so
|
|
1042
|
+
// `waiting_on: []`/`blocking: []` never removed anything and reported no error.
|
|
1043
|
+
const dependencyRecords = Array.isArray(taskData.dependencies) ? taskData.dependencies : [];
|
|
1044
|
+
const currentWaitingOn = dependencyRecords
|
|
1045
|
+
.filter((dep) => dep.task_id === taskId && dep.depends_on)
|
|
1046
|
+
.map((dep) => dep.depends_on);
|
|
1047
|
+
const currentBlocking = dependencyRecords
|
|
1048
|
+
.filter((dep) => dep.depends_on === taskId && dep.task_id)
|
|
1049
|
+
.map((dep) => dep.task_id);
|
|
1050
|
+
// `linked_tasks` entries are link records, not tasks: each has `task_id` and
|
|
1051
|
+
// `link_id` (the two ends of the link) and no `id` at all. Mapping `.id` here
|
|
1052
|
+
// produced `[undefined, ...]`, so every existing link looked like it was no
|
|
1053
|
+
// longer requested, and the removal loop then issued
|
|
1054
|
+
// `DELETE /task/{id}/link/undefined`, which the API rejects. The result was a
|
|
1055
|
+
// `linked_tasks` field documented as "replace" that could never remove
|
|
1056
|
+
// anything. Take whichever end of the record is not the task being updated.
|
|
1057
|
+
const currentLinked = taskData.linked_tasks
|
|
1058
|
+
?.map((link) => (link.task_id === taskData.id ? link.link_id : link.task_id))
|
|
1059
|
+
.filter((id) => Boolean(id)) || [];
|
|
870
1060
|
// Helper function to make dependency API calls
|
|
871
1061
|
async function modifyDependency(operation, type, fromTaskId, toTaskId, dependsOn) {
|
|
872
1062
|
try {
|