@sebastienheyd/clickup-mcp 1.9.0 → 1.10.1
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 +20 -2
- 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/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/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 +80 -18
- package/dist/tools/task-write-tools.d.ts.map +1 -1
- package/dist/tools/task-write-tools.js +138 -98
- package/dist/tools/time-tools.js +3 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,6 +4,24 @@
|
|
|
4
4
|
|
|
5
5
|
Model Context Protocol (MCP) server enabling AI assistants to interact with ClickUp workspaces. Get complete task context with comments and images, search across projects, create and update tasks, collaborate through comments, and track time - all through natural language.
|
|
6
6
|
|
|
7
|
+
## Differences From the Upstream Project
|
|
8
|
+
|
|
9
|
+
This fork is published as [`@sebastienheyd/clickup-mcp`](https://www.npmjs.com/package/@sebastienheyd/clickup-mcp) and follows its own version numbers. Upstream history is merged (last sync: upstream 1.9.0, September 2026), so the two projects share their core; the fork keeps its own additions on top.
|
|
10
|
+
|
|
11
|
+
| Area | Only in this fork |
|
|
12
|
+
|------|-------------------|
|
|
13
|
+
| Task IDs | Custom task IDs (e.g. `SOI-4422`) accepted wherever a `task_id` is expected, time tools included |
|
|
14
|
+
| `searchTasks` | `include_closed` and `archived` filters |
|
|
15
|
+
| `searchSpaces` | `folder_id` to resolve a single folder with its lists and statuses |
|
|
16
|
+
| `createTask` / `updateTask` | Sprint `points`; `remove_assignees`; `null` clears `due_date`, `start_date`, `time_estimate` and `points` |
|
|
17
|
+
| Descriptions | `description` (full replacement) on `updateListInfo` too, and both tools echo the previous description |
|
|
18
|
+
| Comments | `deleteComment` (same guardrails as `editComment`) |
|
|
19
|
+
| Time tracking | `updateTimeEntry` and `deleteTimeEntry`; `getTimeEntries` shows each `entry_id` |
|
|
20
|
+
| Tooling | `Makefile` with the common commands; `npm run cli` understands `key=null` |
|
|
21
|
+
| Runtime | Node.js 20 or newer (upstream: 16) |
|
|
22
|
+
|
|
23
|
+
See [CHANGELOG.md](CHANGELOG.md) for this fork's history and the [upstream changelog](https://github.com/hauptsacheNet/clickup-mcp/blob/main/CHANGELOG.md) for theirs.
|
|
24
|
+
|
|
7
25
|
## This MCP vs Official ClickUp MCP
|
|
8
26
|
|
|
9
27
|
> See also: [Official ClickUp MCP Documentation](https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server)
|
|
@@ -99,7 +117,7 @@ Turn natural language into powerful ClickUp actions:
|
|
|
99
117
|
- Handle time estimates, sprint points and custom field values
|
|
100
118
|
|
|
101
119
|
### 🔒 **Safety Features**
|
|
102
|
-
- **Append-First Descriptions**: `append_description` adds a dated block under the existing description and is the default for notes. `
|
|
120
|
+
- **Append-First Descriptions**: `append_description` adds a dated block under the existing description and is the default for notes. `description` rewrites the whole description on purpose - the previous content is echoed back so a wrong replacement can be undone
|
|
103
121
|
- **Normal Field Updates**: Status, priority, assignees, tags, and dates can be updated normally (easily revertible through ClickUp's history)
|
|
104
122
|
|
|
105
123
|
## Installation
|
|
@@ -291,7 +309,7 @@ By default (`append_description`), content is appended under the existing descri
|
|
|
291
309
|
- Performance requirement: < 2s load time
|
|
292
310
|
```
|
|
293
311
|
|
|
294
|
-
This keeps existing content and a clear audit trail. To correct or restructure a description, `
|
|
312
|
+
This keeps existing content and a clear audit trail. To correct or restructure a description, `description` rewrites it entirely; the tool answers with the previous description so nothing is silently lost. The same two parameters exist on `updateListInfo`.
|
|
295
313
|
|
|
296
314
|
## Writing Images Into Tickets
|
|
297
315
|
|
package/dist/clickup-text.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"clickup-text.d.ts","sourceRoot":"","sources":["../src/clickup-text.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAEjE,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAOpD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE;QACb,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,KAAK,CAAC,EAAE;QACN,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,GAAG,EAAE,MAAM,CAAC;QACZ,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC;IACF,UAAU,CAAC,EAAE,GAAG,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAyCD;;;;;;GAMG;AACH,wBAAsB,uCAAuC,CAC3D,SAAS,EAAE,eAAe,EAAE,GAC3B,OAAO,CAAC,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,GAAG,kBAAkB,CAAC,EAAE,CAAC,
|
|
1
|
+
{"version":3,"file":"clickup-text.d.ts","sourceRoot":"","sources":["../src/clickup-text.ts"],"names":[],"mappings":"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"}
|
package/dist/clickup-text.js
CHANGED
|
@@ -62,11 +62,24 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
62
62
|
// Track current formatting state to avoid unnecessary close/reopen
|
|
63
63
|
let activeBold = false;
|
|
64
64
|
let activeItalic = false;
|
|
65
|
+
let activeStrike = false;
|
|
65
66
|
let activeCode = false;
|
|
67
|
+
// ClickUp emits one '\n' fragment with a code-block attribute per code LINE;
|
|
68
|
+
// consecutive ones belong to the same fenced block when read back as markdown.
|
|
69
|
+
let inCodeBlock = false;
|
|
70
|
+
let currentFenceLang = '';
|
|
71
|
+
const closeCodeFence = () => {
|
|
72
|
+
if (inCodeBlock) {
|
|
73
|
+
currentTextBlock += '```\n';
|
|
74
|
+
inCodeBlock = false;
|
|
75
|
+
currentFenceLang = '';
|
|
76
|
+
}
|
|
77
|
+
};
|
|
66
78
|
for (let i = 0; i < textItems.length; i++) {
|
|
67
79
|
const item = textItems[i];
|
|
68
80
|
// Handle image items
|
|
69
81
|
if (item.type === "image" && item.image && item.image.url) {
|
|
82
|
+
closeCodeFence();
|
|
70
83
|
const imageFileName = item.image.name || item.image.title || "image";
|
|
71
84
|
const imageUrl = item.image.url;
|
|
72
85
|
const altText = item.text || imageFileName;
|
|
@@ -133,6 +146,27 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
133
146
|
else if (typeof item.text === "string") {
|
|
134
147
|
// Check if this is a newline with block formatting (header, blockquote, list)
|
|
135
148
|
if (item.text === '\n' && item.attributes) {
|
|
149
|
+
// Code block formatting: append this line to the open fence (or open one).
|
|
150
|
+
// The attribute value carries the language, either directly or nested as
|
|
151
|
+
// {'code-block': lang}; 'plain' means no language.
|
|
152
|
+
if (item.attributes['code-block']) {
|
|
153
|
+
const rawLang = item.attributes['code-block'];
|
|
154
|
+
const lang = typeof rawLang === 'string' ? rawLang : rawLang?.['code-block'] ?? '';
|
|
155
|
+
const fenceLang = lang === 'plain' ? '' : lang;
|
|
156
|
+
if (inCodeBlock && fenceLang !== currentFenceLang) {
|
|
157
|
+
closeCodeFence();
|
|
158
|
+
}
|
|
159
|
+
if (!inCodeBlock) {
|
|
160
|
+
currentTextBlock += '```' + fenceLang + '\n';
|
|
161
|
+
inCodeBlock = true;
|
|
162
|
+
currentFenceLang = fenceLang;
|
|
163
|
+
}
|
|
164
|
+
currentTextBlock += currentLine + '\n';
|
|
165
|
+
currentLine = "";
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Any other line terminator ends a code block
|
|
169
|
+
closeCodeFence();
|
|
136
170
|
// Header formatting
|
|
137
171
|
if (item.attributes.header) {
|
|
138
172
|
const level = item.attributes.header;
|
|
@@ -163,17 +197,8 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
163
197
|
break;
|
|
164
198
|
}
|
|
165
199
|
}
|
|
166
|
-
// Code block formatting
|
|
167
|
-
else if (item.attributes['code-block']) {
|
|
168
|
-
// Wrap the current line in code block markers
|
|
169
|
-
currentLine = '```\n' + currentLine + '\n```';
|
|
170
|
-
}
|
|
171
200
|
// Add formatted line to text block
|
|
172
|
-
currentTextBlock += currentLine;
|
|
173
|
-
// Add newline unless it's code block (already has newlines)
|
|
174
|
-
if (!item.attributes['code-block']) {
|
|
175
|
-
currentTextBlock += '\n';
|
|
176
|
-
}
|
|
201
|
+
currentTextBlock += currentLine + '\n';
|
|
177
202
|
currentLine = ""; // Reset for next line
|
|
178
203
|
continue;
|
|
179
204
|
}
|
|
@@ -182,15 +207,18 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
182
207
|
// Determine current and next formatting state
|
|
183
208
|
const hasBold = item.attributes?.bold === true;
|
|
184
209
|
const hasItalic = item.attributes?.italic === true;
|
|
210
|
+
const hasStrike = item.attributes?.strike === true;
|
|
185
211
|
const hasLink = item.attributes?.link;
|
|
186
212
|
// Look ahead to next non-newline block
|
|
187
213
|
let nextHasBold = false;
|
|
188
214
|
let nextHasItalic = false;
|
|
215
|
+
let nextHasStrike = false;
|
|
189
216
|
for (let j = i + 1; j < textItems.length; j++) {
|
|
190
217
|
const nextItem = textItems[j];
|
|
191
218
|
if (nextItem.text !== '\n' || !nextItem.attributes) {
|
|
192
219
|
nextHasBold = nextItem.attributes?.bold === true;
|
|
193
220
|
nextHasItalic = nextItem.attributes?.italic === true;
|
|
221
|
+
nextHasStrike = nextItem.attributes?.strike === true;
|
|
194
222
|
break;
|
|
195
223
|
}
|
|
196
224
|
}
|
|
@@ -200,14 +228,20 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
200
228
|
prefix += "**";
|
|
201
229
|
if (hasItalic && !activeItalic)
|
|
202
230
|
prefix += "*";
|
|
231
|
+
if (hasStrike && !activeStrike)
|
|
232
|
+
prefix += "~~";
|
|
203
233
|
// Build suffix (close formatting that won't continue)
|
|
204
234
|
let suffix = "";
|
|
235
|
+
if (hasStrike && !nextHasStrike)
|
|
236
|
+
suffix += "~~";
|
|
205
237
|
if (hasItalic && !nextHasItalic)
|
|
206
238
|
suffix += "*";
|
|
207
239
|
if (hasBold && !nextHasBold)
|
|
208
240
|
suffix += "**";
|
|
209
241
|
// Close formatting that's active but not in this block
|
|
210
242
|
let closingPrefix = "";
|
|
243
|
+
if (activeStrike && !hasStrike)
|
|
244
|
+
closingPrefix += "~~";
|
|
211
245
|
if (activeBold && !hasBold)
|
|
212
246
|
closingPrefix += "**";
|
|
213
247
|
if (activeItalic && !hasItalic)
|
|
@@ -216,6 +250,7 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
216
250
|
// Update state
|
|
217
251
|
activeBold = hasBold && nextHasBold;
|
|
218
252
|
activeItalic = hasItalic && nextHasItalic;
|
|
253
|
+
activeStrike = hasStrike && nextHasStrike;
|
|
219
254
|
// Link formatting (wraps everything)
|
|
220
255
|
if (hasLink) {
|
|
221
256
|
formattedText = `[${formattedText}](${hasLink})`;
|
|
@@ -227,6 +262,7 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
227
262
|
// Add to current line (not text block yet)
|
|
228
263
|
if (item.text === '\n') {
|
|
229
264
|
// Plain newline without formatting
|
|
265
|
+
closeCodeFence();
|
|
230
266
|
currentTextBlock += currentLine + '\n';
|
|
231
267
|
currentLine = "";
|
|
232
268
|
}
|
|
@@ -245,6 +281,7 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
245
281
|
}
|
|
246
282
|
}
|
|
247
283
|
// Add any remaining text
|
|
284
|
+
closeCodeFence();
|
|
248
285
|
if (currentLine) {
|
|
249
286
|
currentTextBlock += currentLine;
|
|
250
287
|
}
|
|
@@ -439,7 +476,7 @@ function buildImageFragment(attachment, caption) {
|
|
|
439
476
|
* fragment (e.g. ?comment=... deep links) do NOT match, because a mention would
|
|
440
477
|
* either not resolve or lose the anchor - those stay ordinary links.
|
|
441
478
|
*/
|
|
442
|
-
const CLICKUP_TASK_URL_PATTERN = /^https?:\/\/app\.clickup\.com\/t\/(?:\d+\/)?([a-z0-9]{6,
|
|
479
|
+
const CLICKUP_TASK_URL_PATTERN = /^https?:\/\/app\.clickup\.com\/t\/(?:\d+\/)?([a-z0-9]{6,16})\/?$/;
|
|
443
480
|
/**
|
|
444
481
|
* Extract the task ID from a ClickUp task URL, or null if it is not one.
|
|
445
482
|
*/
|
|
@@ -558,6 +595,14 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0, attachmentsByS
|
|
|
558
595
|
for (let i = 0; i < nodes.length; i++) {
|
|
559
596
|
const node = nodes[i];
|
|
560
597
|
const currentAttrs = { ...inheritedAttrs };
|
|
598
|
+
// ClickUp has no paragraph margins: two paragraphs separated by a single '\n'
|
|
599
|
+
// render as consecutive lines. The UI stores a paragraph break as an extra
|
|
600
|
+
// empty '\n' fragment (what a user gets by pressing Enter twice), so emit one
|
|
601
|
+
// between adjacent flow blocks. Headings, code blocks and blockquotes bring
|
|
602
|
+
// their own spacing - a blank line next to them would double the gap.
|
|
603
|
+
if (i > 0 && needsBlankLineBetween(nodes[i - 1], node)) {
|
|
604
|
+
blocks.push({ text: '\n', attributes: {} });
|
|
605
|
+
}
|
|
561
606
|
switch (node.type) {
|
|
562
607
|
case 'heading':
|
|
563
608
|
// Process heading content with inline formatting
|
|
@@ -621,42 +666,139 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0, attachmentsByS
|
|
|
621
666
|
// Code block
|
|
622
667
|
const codeNode = node;
|
|
623
668
|
if (codeNode.value) {
|
|
624
|
-
blocks
|
|
625
|
-
blocks.push({
|
|
626
|
-
text: '\n',
|
|
627
|
-
attributes: { 'code-block': { 'code-block': codeNode.lang || 'plain' } }
|
|
628
|
-
});
|
|
669
|
+
pushCodeBlockLines(blocks, codeNode.value, codeNode.lang || 'plain');
|
|
629
670
|
}
|
|
630
671
|
break;
|
|
631
672
|
case 'thematicBreak':
|
|
632
673
|
// Horizontal rule - just add a line break
|
|
633
674
|
blocks.push({ text: '\n', attributes: {} });
|
|
634
675
|
break;
|
|
676
|
+
case 'table': {
|
|
677
|
+
// ClickUp comments cannot render tables (there is no table fragment in the
|
|
678
|
+
// comment format - unknown attributes are stored but render as plain text).
|
|
679
|
+
// Re-render the table as an aligned pipe table inside a code block so the
|
|
680
|
+
// information survives and stays readable in monospace.
|
|
681
|
+
const tableText = serializeTableAsAlignedPipes(node);
|
|
682
|
+
if (tableText) {
|
|
683
|
+
pushCodeBlockLines(blocks, tableText, 'plain');
|
|
684
|
+
}
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
635
687
|
default:
|
|
636
688
|
// For any other block-level nodes, try to process children
|
|
637
689
|
if ('children' in node && Array.isArray(node.children)) {
|
|
638
690
|
walkMdastNodes(node.children, currentAttrs, blocks, depth, attachmentsBySrc);
|
|
639
691
|
}
|
|
692
|
+
else if ('value' in node && typeof node.value === 'string') {
|
|
693
|
+
// Last-resort safety net: never drop text content silently
|
|
694
|
+
blocks.push({ text: node.value, attributes: {} });
|
|
695
|
+
}
|
|
640
696
|
break;
|
|
641
697
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Block types that render without their own vertical margin in ClickUp comments.
|
|
702
|
+
* Two of them in a row need an explicit empty line to read as separate blocks.
|
|
703
|
+
*/
|
|
704
|
+
const FLOW_BLOCK_TYPES = new Set(['paragraph', 'list']);
|
|
705
|
+
/**
|
|
706
|
+
* Block types emitted as code-block lines (tables are rendered inside a code block).
|
|
707
|
+
* Quill fuses consecutive code-block lines into ONE block whatever the source looked
|
|
708
|
+
* like, so two of them in a row need an empty line in between - otherwise a command
|
|
709
|
+
* and its output, or a code block and a table, collapse into a single block, and the
|
|
710
|
+
* read side (which groups consecutive code-block lines into one fence) can no longer
|
|
711
|
+
* tell them apart.
|
|
712
|
+
*/
|
|
713
|
+
const CODE_BLOCK_TYPES = new Set(['code', 'table']);
|
|
714
|
+
function needsBlankLineBetween(prev, next) {
|
|
715
|
+
if (FLOW_BLOCK_TYPES.has(prev.type) && FLOW_BLOCK_TYPES.has(next.type)) {
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
return CODE_BLOCK_TYPES.has(prev.type) && CODE_BLOCK_TYPES.has(next.type);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Emit a (possibly multi-line) code block the way ClickUp's Quill-based format
|
|
722
|
+
* expects it: block attributes apply per line, so EVERY line needs its own '\n'
|
|
723
|
+
* fragment carrying the code-block attribute. A single text fragment with embedded
|
|
724
|
+
* newlines renders only its last line as code - the rest degrades to plain text.
|
|
725
|
+
*/
|
|
726
|
+
function pushCodeBlockLines(blocks, code, lang) {
|
|
727
|
+
for (const line of code.split('\n')) {
|
|
728
|
+
if (line) {
|
|
729
|
+
blocks.push({ text: line, attributes: {} });
|
|
730
|
+
}
|
|
731
|
+
blocks.push({
|
|
732
|
+
text: '\n',
|
|
733
|
+
attributes: { 'code-block': { 'code-block': lang } }
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Serialize phrasing content back to compact markdown for use inside a code block.
|
|
739
|
+
* Inline formatting markers are kept so nothing is lost, even though a code block
|
|
740
|
+
* renders them literally.
|
|
741
|
+
*/
|
|
742
|
+
function serializePhrasingToMarkdown(nodes) {
|
|
743
|
+
let out = '';
|
|
744
|
+
for (const node of nodes) {
|
|
745
|
+
switch (node.type) {
|
|
746
|
+
case 'text':
|
|
747
|
+
out += node.value;
|
|
748
|
+
break;
|
|
749
|
+
case 'strong':
|
|
750
|
+
out += `**${serializePhrasingToMarkdown(node.children)}**`;
|
|
751
|
+
break;
|
|
752
|
+
case 'emphasis':
|
|
753
|
+
out += `*${serializePhrasingToMarkdown(node.children)}*`;
|
|
754
|
+
break;
|
|
755
|
+
case 'delete':
|
|
756
|
+
out += `~~${serializePhrasingToMarkdown(node.children)}~~`;
|
|
757
|
+
break;
|
|
758
|
+
case 'inlineCode':
|
|
759
|
+
out += `\`${node.value}\``;
|
|
760
|
+
break;
|
|
761
|
+
case 'link':
|
|
762
|
+
out += `[${serializePhrasingToMarkdown(node.children)}](${node.url})`;
|
|
763
|
+
break;
|
|
764
|
+
case 'image':
|
|
765
|
+
out += node.alt || node.url;
|
|
766
|
+
break;
|
|
767
|
+
case 'break':
|
|
768
|
+
out += ' ';
|
|
769
|
+
break;
|
|
770
|
+
default:
|
|
771
|
+
if ('value' in node && typeof node.value === 'string') {
|
|
772
|
+
out += node.value;
|
|
773
|
+
}
|
|
774
|
+
else if ('children' in node && Array.isArray(node.children)) {
|
|
775
|
+
out += serializePhrasingToMarkdown(node.children);
|
|
776
|
+
}
|
|
777
|
+
break;
|
|
646
778
|
}
|
|
647
779
|
}
|
|
780
|
+
return out;
|
|
648
781
|
}
|
|
649
782
|
/**
|
|
650
|
-
*
|
|
651
|
-
* Several blank lines count as one, as in rendered markdown.
|
|
783
|
+
* Render an mdast table as a column-aligned pipe table string.
|
|
652
784
|
*/
|
|
653
|
-
function
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
785
|
+
function serializeTableAsAlignedPipes(table) {
|
|
786
|
+
const rows = table.children.map((row) => row.children.map((cell) => serializePhrasingToMarkdown(cell.children).replace(/\|/g, '\\|').trim()));
|
|
787
|
+
if (rows.length === 0) {
|
|
788
|
+
return '';
|
|
789
|
+
}
|
|
790
|
+
const colCount = Math.max(...rows.map((r) => r.length));
|
|
791
|
+
const widths = [];
|
|
792
|
+
for (let col = 0; col < colCount; col++) {
|
|
793
|
+
widths[col] = Math.max(3, ...rows.map((r) => (r[col] ?? '').length));
|
|
658
794
|
}
|
|
659
|
-
|
|
795
|
+
const renderRow = (cells) => '| ' + widths.map((w, col) => (cells[col] ?? '').padEnd(w)).join(' | ') + ' |';
|
|
796
|
+
const lines = [renderRow(rows[0])];
|
|
797
|
+
lines.push('| ' + widths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
|
|
798
|
+
for (const row of rows.slice(1)) {
|
|
799
|
+
lines.push(renderRow(row));
|
|
800
|
+
}
|
|
801
|
+
return lines.join('\n');
|
|
660
802
|
}
|
|
661
803
|
/**
|
|
662
804
|
* Recursively walk phrasing content (inline nodes) and build ClickUp blocks
|
|
@@ -705,6 +847,11 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks, attachmentsBySrc) {
|
|
|
705
847
|
currentAttrs.italic = true;
|
|
706
848
|
walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
|
|
707
849
|
break;
|
|
850
|
+
case 'delete':
|
|
851
|
+
// GFM strikethrough (~~text~~) - ClickUp renders this via the strike attribute
|
|
852
|
+
currentAttrs.strike = true;
|
|
853
|
+
walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
|
|
854
|
+
break;
|
|
708
855
|
case 'inlineCode':
|
|
709
856
|
// Inline code
|
|
710
857
|
if (node.value) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** A task comment as returned by GET /api/v2/task/{task_id}/comment or GET /comment/{id}/reply */
|
|
2
|
+
export interface ExistingComment {
|
|
3
|
+
id: string;
|
|
4
|
+
date: string;
|
|
5
|
+
comment?: any[];
|
|
6
|
+
comment_text?: string;
|
|
7
|
+
user?: {
|
|
8
|
+
id?: number | string;
|
|
9
|
+
username?: string;
|
|
10
|
+
};
|
|
11
|
+
reply_count?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Cursor into the comment list: the date and id of the last comment of the previous page */
|
|
14
|
+
export interface CommentPageCursor {
|
|
15
|
+
start: string;
|
|
16
|
+
startId: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Never page further back than this. A busy ticket would otherwise walk its
|
|
20
|
+
* entire comment history and eat the 100 calls/minute budget.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MAX_COMMENT_PAGES = 10;
|
|
23
|
+
/** Page size ClickUp uses for the comment list - a shorter page means the last page. */
|
|
24
|
+
export declare const COMMENTS_PER_PAGE = 25;
|
|
25
|
+
/** One page of task comments, newest first, 25 per page */
|
|
26
|
+
export declare function fetchCommentPage(taskId: string, cursor?: CommentPageCursor): Promise<ExistingComment[]>;
|
|
27
|
+
/** The top-level comments of a task plus whether the history is complete. */
|
|
28
|
+
export interface CommentHistory {
|
|
29
|
+
/** Top-level comments, newest first */
|
|
30
|
+
comments: ExistingComment[];
|
|
31
|
+
/**
|
|
32
|
+
* Why older comments are missing, or null when the whole list was loaded.
|
|
33
|
+
* `capped`: the task has more comments than MAX_COMMENT_PAGES pages;
|
|
34
|
+
* `error`: a page request failed (rate limit, 5xx...) - `comments` holds what was
|
|
35
|
+
* loaded before the failure, possibly nothing.
|
|
36
|
+
*/
|
|
37
|
+
incomplete: {
|
|
38
|
+
reason: "capped" | "error";
|
|
39
|
+
detail: string;
|
|
40
|
+
} | null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* All top-level comments of a task, newest first.
|
|
44
|
+
*
|
|
45
|
+
* The comment list endpoint returns 25 comments per page, so longer histories are
|
|
46
|
+
* paged with `start`/`start_id`. Paging is capped at MAX_COMMENT_PAGES (250 comments)
|
|
47
|
+
* to protect the API budget. Never throws: a failing page (typically a 429 close to
|
|
48
|
+
* the API limit on exactly the busy tasks that need paging) keeps the comments
|
|
49
|
+
* already loaded and is reported through `incomplete`, so the caller can show a
|
|
50
|
+
* partial history with a warning instead of silently showing none.
|
|
51
|
+
*/
|
|
52
|
+
export declare function fetchAllTopLevelComments(taskId: string): Promise<CommentHistory>;
|
|
53
|
+
/** Result of looking a top-level comment up in a task's comment list. */
|
|
54
|
+
export interface TopLevelCommentLookup {
|
|
55
|
+
/** The comment, or undefined when it was not found in the searched range */
|
|
56
|
+
comment: ExistingComment | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* true when the whole comment list was searched, so "not found" is definitive;
|
|
59
|
+
* false when the search stopped at MAX_COMMENT_PAGES with older comments left
|
|
60
|
+
* unread, so the id may still be a valid but old top-level comment.
|
|
61
|
+
*/
|
|
62
|
+
exhaustive: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Find a top-level comment of a task by id, paging as far as MAX_COMMENT_PAGES.
|
|
66
|
+
*
|
|
67
|
+
* Unlike editComment's lookup this does not stop at the edit window, because a
|
|
68
|
+
* thread parent can be arbitrarily old. `comment` is undefined when the id is not
|
|
69
|
+
* a top-level comment of this task - which also catches reply ids, since replies
|
|
70
|
+
* never appear in the task's comment list. Callers must check `exhaustive` before
|
|
71
|
+
* telling the user the id is invalid: past the page cap the answer is only
|
|
72
|
+
* "not among the newest comments".
|
|
73
|
+
*/
|
|
74
|
+
export declare function findTopLevelComment(taskId: string, commentId: string): Promise<TopLevelCommentLookup>;
|
|
75
|
+
/**
|
|
76
|
+
* The replies inside a comment thread, oldest first.
|
|
77
|
+
*
|
|
78
|
+
* `GET /task/{id}/comment` only returns top-level comments; the replies of a
|
|
79
|
+
* thread (Threaded Comments ClickApp) live behind `GET /comment/{id}/reply`.
|
|
80
|
+
* Any failure (non-ok response, network error, malformed body) is logged and
|
|
81
|
+
* returns an empty array so one broken thread does not take down the whole
|
|
82
|
+
* task view.
|
|
83
|
+
*/
|
|
84
|
+
export declare function fetchCommentReplies(commentId: string): Promise<ExistingComment[]>;
|
|
85
|
+
/**
|
|
86
|
+
* Never fetch more threads than this per task read, and never more than a few
|
|
87
|
+
* at once - together with MAX_COMMENT_PAGES this keeps a single getTaskById
|
|
88
|
+
* within ClickUp's 100 calls/minute budget even on a heavily threaded task.
|
|
89
|
+
*/
|
|
90
|
+
export declare const MAX_REPLY_FETCHES = 30;
|
|
91
|
+
/**
|
|
92
|
+
* Fetch the replies of every comment with reply_count > 0, bounded in count and
|
|
93
|
+
* concurrency. Returns a map of comment id -> replies; a thread that was skipped
|
|
94
|
+
* (over the cap) or failed to load is simply absent or empty, so callers can
|
|
95
|
+
* render a "replies not loaded" hint from reply_count.
|
|
96
|
+
*/
|
|
97
|
+
export declare function fetchRepliesByComment(comments: ExistingComment[]): Promise<Map<string, ExistingComment[]>>;
|
|
98
|
+
//# sourceMappingURL=comments.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"comments.d.ts","sourceRoot":"","sources":["../../src/shared/comments.ts"],"names":[],"mappings":"AAEA,kGAAkG;AAClG,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,6FAA6F;AAC7F,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,wFAAwF;AACxF,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,2DAA2D;AAC3D,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,iBAAiB,GACzB,OAAO,CAAC,eAAe,EAAE,CAAC,CAqB5B;AAED,6EAA6E;AAC7E,MAAM,WAAW,cAAc;IAC7B,uCAAuC;IACvC,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B;;;;;OAKG;IACH,UAAU,EAAE;QAAE,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CACnE;AAED;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAsCtF;AAED,yEAAyE;AACzE,MAAM,WAAW,qBAAqB;IACpC,4EAA4E;IAC5E,OAAO,EAAE,eAAe,GAAG,SAAS,CAAC;IACrC;;;;OAIG;IACH,UAAU,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,qBAAqB,CAAC,CAiBhC;AAED;;;;;;;;GAQG;AACH,wBAAsB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAsBvF;AAED;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAGpC;;;;;GAKG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,eAAe,EAAE,GAC1B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,eAAe,EAAE,CAAC,CAAC,CAsBzC"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_REPLY_FETCHES = exports.COMMENTS_PER_PAGE = exports.MAX_COMMENT_PAGES = void 0;
|
|
4
|
+
exports.fetchCommentPage = fetchCommentPage;
|
|
5
|
+
exports.fetchAllTopLevelComments = fetchAllTopLevelComments;
|
|
6
|
+
exports.findTopLevelComment = findTopLevelComment;
|
|
7
|
+
exports.fetchCommentReplies = fetchCommentReplies;
|
|
8
|
+
exports.fetchRepliesByComment = fetchRepliesByComment;
|
|
9
|
+
const config_1 = require("./config");
|
|
10
|
+
/**
|
|
11
|
+
* Never page further back than this. A busy ticket would otherwise walk its
|
|
12
|
+
* entire comment history and eat the 100 calls/minute budget.
|
|
13
|
+
*/
|
|
14
|
+
exports.MAX_COMMENT_PAGES = 10;
|
|
15
|
+
/** Page size ClickUp uses for the comment list - a shorter page means the last page. */
|
|
16
|
+
exports.COMMENTS_PER_PAGE = 25;
|
|
17
|
+
/** One page of task comments, newest first, 25 per page */
|
|
18
|
+
async function fetchCommentPage(taskId, cursor) {
|
|
19
|
+
// Note there is no `start_date` parameter - passing one is silently ignored.
|
|
20
|
+
// Older pages are reached with `start` + `start_id` of the previous page's last entry.
|
|
21
|
+
const query = cursor
|
|
22
|
+
? `?${new URLSearchParams({ start: cursor.start, start_id: cursor.startId })}`
|
|
23
|
+
: "";
|
|
24
|
+
const response = await fetch(`https://api.clickup.com/api/v2/task/${taskId}/comment${query}`, { headers: { Authorization: config_1.CONFIG.apiKey } });
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
const errorData = await response.json().catch(() => ({}));
|
|
27
|
+
throw new Error(`Error loading comments of task ${taskId}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
|
|
28
|
+
}
|
|
29
|
+
const data = await response.json();
|
|
30
|
+
return Array.isArray(data.comments) ? data.comments : [];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* All top-level comments of a task, newest first.
|
|
34
|
+
*
|
|
35
|
+
* The comment list endpoint returns 25 comments per page, so longer histories are
|
|
36
|
+
* paged with `start`/`start_id`. Paging is capped at MAX_COMMENT_PAGES (250 comments)
|
|
37
|
+
* to protect the API budget. Never throws: a failing page (typically a 429 close to
|
|
38
|
+
* the API limit on exactly the busy tasks that need paging) keeps the comments
|
|
39
|
+
* already loaded and is reported through `incomplete`, so the caller can show a
|
|
40
|
+
* partial history with a warning instead of silently showing none.
|
|
41
|
+
*/
|
|
42
|
+
async function fetchAllTopLevelComments(taskId) {
|
|
43
|
+
const comments = [];
|
|
44
|
+
let cursor;
|
|
45
|
+
let pages = 0;
|
|
46
|
+
let lastPageWasFull = false;
|
|
47
|
+
while (pages < exports.MAX_COMMENT_PAGES) {
|
|
48
|
+
let page;
|
|
49
|
+
try {
|
|
50
|
+
page = await fetchCommentPage(taskId, cursor);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
54
|
+
console.error(`Error fetching comments of task ${taskId} (page ${pages + 1}, ${comments.length} loaded so far): ${detail}`);
|
|
55
|
+
return { comments, incomplete: { reason: "error", detail } };
|
|
56
|
+
}
|
|
57
|
+
pages++;
|
|
58
|
+
if (page.length === 0) {
|
|
59
|
+
lastPageWasFull = false;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
comments.push(...page);
|
|
63
|
+
lastPageWasFull = page.length >= exports.COMMENTS_PER_PAGE;
|
|
64
|
+
if (!lastPageWasFull) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
const oldest = page[page.length - 1];
|
|
68
|
+
cursor = { start: String(oldest.date), startId: String(oldest.id) };
|
|
69
|
+
}
|
|
70
|
+
if (lastPageWasFull && pages >= exports.MAX_COMMENT_PAGES) {
|
|
71
|
+
const detail = `the task has more than ${comments.length} top-level comments; loading stops after ${exports.MAX_COMMENT_PAGES} pages to protect the API budget`;
|
|
72
|
+
console.error(`Task ${taskId}: older comments were not loaded - ${detail}.`);
|
|
73
|
+
return { comments, incomplete: { reason: "capped", detail } };
|
|
74
|
+
}
|
|
75
|
+
return { comments, incomplete: null };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Find a top-level comment of a task by id, paging as far as MAX_COMMENT_PAGES.
|
|
79
|
+
*
|
|
80
|
+
* Unlike editComment's lookup this does not stop at the edit window, because a
|
|
81
|
+
* thread parent can be arbitrarily old. `comment` is undefined when the id is not
|
|
82
|
+
* a top-level comment of this task - which also catches reply ids, since replies
|
|
83
|
+
* never appear in the task's comment list. Callers must check `exhaustive` before
|
|
84
|
+
* telling the user the id is invalid: past the page cap the answer is only
|
|
85
|
+
* "not among the newest comments".
|
|
86
|
+
*/
|
|
87
|
+
async function findTopLevelComment(taskId, commentId) {
|
|
88
|
+
let cursor;
|
|
89
|
+
for (let pages = 0; pages < exports.MAX_COMMENT_PAGES; pages++) {
|
|
90
|
+
const page = await fetchCommentPage(taskId, cursor);
|
|
91
|
+
const match = page.find((entry) => String(entry.id) === String(commentId));
|
|
92
|
+
if (match) {
|
|
93
|
+
return { comment: match, exhaustive: true };
|
|
94
|
+
}
|
|
95
|
+
if (page.length < exports.COMMENTS_PER_PAGE) {
|
|
96
|
+
return { comment: undefined, exhaustive: true };
|
|
97
|
+
}
|
|
98
|
+
const oldest = page[page.length - 1];
|
|
99
|
+
cursor = { start: String(oldest.date), startId: String(oldest.id) };
|
|
100
|
+
}
|
|
101
|
+
return { comment: undefined, exhaustive: false };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The replies inside a comment thread, oldest first.
|
|
105
|
+
*
|
|
106
|
+
* `GET /task/{id}/comment` only returns top-level comments; the replies of a
|
|
107
|
+
* thread (Threaded Comments ClickApp) live behind `GET /comment/{id}/reply`.
|
|
108
|
+
* Any failure (non-ok response, network error, malformed body) is logged and
|
|
109
|
+
* returns an empty array so one broken thread does not take down the whole
|
|
110
|
+
* task view.
|
|
111
|
+
*/
|
|
112
|
+
async function fetchCommentReplies(commentId) {
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(`https://api.clickup.com/api/v2/comment/${commentId}/reply`, { headers: { Authorization: config_1.CONFIG.apiKey } });
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
const errorData = await response.json().catch(() => ({}));
|
|
117
|
+
console.error(`Error fetching replies for comment ${commentId}: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
const data = await response.json();
|
|
121
|
+
const replies = Array.isArray(data?.comments) ? data.comments : [];
|
|
122
|
+
return replies.sort((a, b) => Number(a.date) - Number(b.date));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.error(`Error fetching replies for comment ${commentId}:`, error);
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Never fetch more threads than this per task read, and never more than a few
|
|
131
|
+
* at once - together with MAX_COMMENT_PAGES this keeps a single getTaskById
|
|
132
|
+
* within ClickUp's 100 calls/minute budget even on a heavily threaded task.
|
|
133
|
+
*/
|
|
134
|
+
exports.MAX_REPLY_FETCHES = 30;
|
|
135
|
+
const REPLY_FETCH_CONCURRENCY = 5;
|
|
136
|
+
/**
|
|
137
|
+
* Fetch the replies of every comment with reply_count > 0, bounded in count and
|
|
138
|
+
* concurrency. Returns a map of comment id -> replies; a thread that was skipped
|
|
139
|
+
* (over the cap) or failed to load is simply absent or empty, so callers can
|
|
140
|
+
* render a "replies not loaded" hint from reply_count.
|
|
141
|
+
*/
|
|
142
|
+
async function fetchRepliesByComment(comments) {
|
|
143
|
+
const threaded = comments.filter((comment) => (comment.reply_count ?? 0) > 0);
|
|
144
|
+
const toFetch = threaded.slice(0, exports.MAX_REPLY_FETCHES);
|
|
145
|
+
if (threaded.length > toFetch.length) {
|
|
146
|
+
console.error(`Skipping replies of ${threaded.length - toFetch.length} comment thread(s) - only the ${exports.MAX_REPLY_FETCHES} newest threads are loaded to stay within the API budget.`);
|
|
147
|
+
}
|
|
148
|
+
const replies = new Map();
|
|
149
|
+
let next = 0;
|
|
150
|
+
const worker = async () => {
|
|
151
|
+
while (next < toFetch.length) {
|
|
152
|
+
const comment = toFetch[next++];
|
|
153
|
+
replies.set(String(comment.id), await fetchCommentReplies(String(comment.id)));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
await Promise.all(Array.from({ length: Math.min(REPLY_FETCH_CONCURRENCY, toFetch.length) }, worker));
|
|
157
|
+
return replies;
|
|
158
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/shared/config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,oBAA2D,CAAC;AAkDvF,MAAM,MAAM,OAAO,GAAG,cAAc,GAAG,MAAM,GAAG,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/shared/config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,oBAA2D,CAAC;AAkDvF,MAAM,MAAM,OAAO,GAAG,cAAc,GAAG,MAAM,GAAG,OAAO,CAAC;AA8CxD,eAAO,MAAM,MAAM;;;;;;;;;CAqBlB,CAAC"}
|