@taazkareem/clickup-mcp-server 0.6.1 → 0.6.3
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 +6 -6
- package/build/index.js +1 -0
- package/build/logger.js +26 -1
- package/build/server.js +1 -1
- package/build/services/clickup/base.js +22 -1
- package/build/services/clickup/bulk.js +76 -45
- package/build/services/clickup/index.js +2 -2
- package/build/services/clickup/task/index.js +32 -0
- package/build/services/clickup/task/task-attachments.js +97 -0
- package/build/services/clickup/task/task-comments.js +104 -0
- package/build/services/clickup/task/task-core.js +477 -0
- package/build/services/clickup/task/task-custom-fields.js +97 -0
- package/build/services/clickup/task/task-search.js +462 -0
- package/build/services/clickup/task/task-service.js +25 -0
- package/build/services/clickup/task/task-tags.js +101 -0
- package/build/services/clickup/workspace.js +81 -36
- package/build/tools/folder.js +1 -1
- package/build/tools/list.js +2 -4
- package/build/tools/task/attachments.js +18 -5
- package/build/tools/task/attachments.types.js +9 -0
- package/build/tools/task/bulk-operations.js +111 -15
- package/build/tools/task/handlers.js +169 -24
- package/build/tools/task/index.js +1 -1
- package/build/tools/task/main.js +36 -1
- package/build/tools/task/single-operations.js +51 -4
- package/build/tools/task/utilities.js +24 -71
- package/build/tools/utils.js +2 -2
- package/build/utils/date-utils.js +149 -30
- package/build/utils/resolver-utils.js +33 -40
- package/build/utils/sponsor-service.js +1 -1
- package/package.json +1 -1
- package/build/services/clickup/task.js +0 -701
|
@@ -1,701 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
|
|
3
|
-
* SPDX-License-Identifier: MIT
|
|
4
|
-
*
|
|
5
|
-
* ClickUp Task Service
|
|
6
|
-
*
|
|
7
|
-
* Handles all operations related to tasks in ClickUp, including:
|
|
8
|
-
* - Creating tasks (single and bulk)
|
|
9
|
-
* - Retrieving tasks (single or multiple)
|
|
10
|
-
* - Updating tasks
|
|
11
|
-
* - Deleting tasks
|
|
12
|
-
* - Finding tasks by name
|
|
13
|
-
*/
|
|
14
|
-
import { BaseClickUpService, ErrorCode, ClickUpServiceError } from './base.js';
|
|
15
|
-
import { ListService } from './list.js';
|
|
16
|
-
import { estimateTokensFromObject, wouldExceedTokenLimit } from '../../utils/token-utils.js';
|
|
17
|
-
export class TaskService extends BaseClickUpService {
|
|
18
|
-
constructor(apiKey, teamId, baseUrl, workspaceService) {
|
|
19
|
-
super(apiKey, teamId, baseUrl);
|
|
20
|
-
this.workspaceService = null;
|
|
21
|
-
if (workspaceService) {
|
|
22
|
-
this.workspaceService = workspaceService;
|
|
23
|
-
this.logOperation('constructor', { usingSharedWorkspaceService: true });
|
|
24
|
-
}
|
|
25
|
-
// Initialize list service for list lookups
|
|
26
|
-
this.listService = new ListService(apiKey, teamId, baseUrl, this.workspaceService);
|
|
27
|
-
this.logOperation('constructor', { initialized: true });
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Helper method to handle errors consistently
|
|
31
|
-
* @param error The error that occurred
|
|
32
|
-
* @param message Optional custom error message
|
|
33
|
-
* @returns A ClickUpServiceError
|
|
34
|
-
*/
|
|
35
|
-
handleError(error, message) {
|
|
36
|
-
if (error instanceof ClickUpServiceError) {
|
|
37
|
-
return error;
|
|
38
|
-
}
|
|
39
|
-
return new ClickUpServiceError(message || `Task service error: ${error.message}`, ErrorCode.UNKNOWN, error);
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Build URL parameters from task filters
|
|
43
|
-
* @param filters Task filters to convert to URL parameters
|
|
44
|
-
* @returns URLSearchParams object
|
|
45
|
-
*/
|
|
46
|
-
buildTaskFilterParams(filters) {
|
|
47
|
-
const params = new URLSearchParams();
|
|
48
|
-
// Add all filters to the query parameters
|
|
49
|
-
if (filters.include_closed)
|
|
50
|
-
params.append('include_closed', String(filters.include_closed));
|
|
51
|
-
if (filters.subtasks)
|
|
52
|
-
params.append('subtasks', String(filters.subtasks));
|
|
53
|
-
if (filters.page)
|
|
54
|
-
params.append('page', String(filters.page));
|
|
55
|
-
if (filters.order_by)
|
|
56
|
-
params.append('order_by', filters.order_by);
|
|
57
|
-
if (filters.reverse)
|
|
58
|
-
params.append('reverse', String(filters.reverse));
|
|
59
|
-
// Array parameters
|
|
60
|
-
if (filters.statuses && filters.statuses.length > 0) {
|
|
61
|
-
filters.statuses.forEach(status => params.append('statuses[]', status));
|
|
62
|
-
}
|
|
63
|
-
if (filters.assignees && filters.assignees.length > 0) {
|
|
64
|
-
filters.assignees.forEach(assignee => params.append('assignees[]', assignee));
|
|
65
|
-
}
|
|
66
|
-
// Team tasks endpoint specific parameters
|
|
67
|
-
if (filters.tags && filters.tags.length > 0) {
|
|
68
|
-
filters.tags.forEach(tag => params.append('tags[]', tag));
|
|
69
|
-
}
|
|
70
|
-
if (filters.list_ids && filters.list_ids.length > 0) {
|
|
71
|
-
filters.list_ids.forEach(id => params.append('list_ids[]', id));
|
|
72
|
-
}
|
|
73
|
-
if (filters.folder_ids && filters.folder_ids.length > 0) {
|
|
74
|
-
filters.folder_ids.forEach(id => params.append('folder_ids[]', id));
|
|
75
|
-
}
|
|
76
|
-
if (filters.space_ids && filters.space_ids.length > 0) {
|
|
77
|
-
filters.space_ids.forEach(id => params.append('space_ids[]', id));
|
|
78
|
-
}
|
|
79
|
-
if (filters.archived !== undefined)
|
|
80
|
-
params.append('archived', String(filters.archived));
|
|
81
|
-
if (filters.include_closed_lists !== undefined)
|
|
82
|
-
params.append('include_closed_lists', String(filters.include_closed_lists));
|
|
83
|
-
if (filters.include_archived_lists !== undefined)
|
|
84
|
-
params.append('include_archived_lists', String(filters.include_archived_lists));
|
|
85
|
-
if (filters.include_compact_time_entries !== undefined)
|
|
86
|
-
params.append('include_compact_time_entries', String(filters.include_compact_time_entries));
|
|
87
|
-
// Date filters
|
|
88
|
-
if (filters.due_date_gt)
|
|
89
|
-
params.append('due_date_gt', String(filters.due_date_gt));
|
|
90
|
-
if (filters.due_date_lt)
|
|
91
|
-
params.append('due_date_lt', String(filters.due_date_lt));
|
|
92
|
-
if (filters.date_created_gt)
|
|
93
|
-
params.append('date_created_gt', String(filters.date_created_gt));
|
|
94
|
-
if (filters.date_created_lt)
|
|
95
|
-
params.append('date_created_lt', String(filters.date_created_lt));
|
|
96
|
-
if (filters.date_updated_gt)
|
|
97
|
-
params.append('date_updated_gt', String(filters.date_updated_gt));
|
|
98
|
-
if (filters.date_updated_lt)
|
|
99
|
-
params.append('date_updated_lt', String(filters.date_updated_lt));
|
|
100
|
-
// Handle custom fields if present
|
|
101
|
-
if (filters.custom_fields) {
|
|
102
|
-
Object.entries(filters.custom_fields).forEach(([key, value]) => {
|
|
103
|
-
params.append(`custom_fields[${key}]`, String(value));
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
return params;
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Extract priority value from a task
|
|
110
|
-
* @param task The task to extract priority from
|
|
111
|
-
* @returns TaskPriority or null
|
|
112
|
-
*/
|
|
113
|
-
extractPriorityValue(task) {
|
|
114
|
-
if (!task.priority || !task.priority.id) {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
const priorityValue = parseInt(task.priority.id);
|
|
118
|
-
// Ensure it's in the valid range 1-4
|
|
119
|
-
if (isNaN(priorityValue) || priorityValue < 1 || priorityValue > 4) {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
return priorityValue;
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Extract task data for creation/duplication
|
|
126
|
-
* @param task The source task
|
|
127
|
-
* @param nameOverride Optional override for the task name
|
|
128
|
-
* @returns CreateTaskData object
|
|
129
|
-
*/
|
|
130
|
-
extractTaskData(task, nameOverride) {
|
|
131
|
-
return {
|
|
132
|
-
name: nameOverride || task.name,
|
|
133
|
-
description: task.description || '',
|
|
134
|
-
status: task.status?.status,
|
|
135
|
-
priority: this.extractPriorityValue(task),
|
|
136
|
-
due_date: task.due_date ? Number(task.due_date) : undefined,
|
|
137
|
-
assignees: task.assignees?.map(a => a.id) || []
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* Find a matching task by name using different matching strategies
|
|
142
|
-
* @param tasks List of tasks to search
|
|
143
|
-
* @param taskName Name to search for
|
|
144
|
-
* @returns Matching task or null
|
|
145
|
-
*/
|
|
146
|
-
findMatchingTask(tasks, taskName) {
|
|
147
|
-
// Normalize the search term
|
|
148
|
-
const normalizedSearchTerm = taskName.toLowerCase().trim();
|
|
149
|
-
// First try exact match
|
|
150
|
-
let matchingTask = tasks.find(task => task.name.toLowerCase().trim() === normalizedSearchTerm);
|
|
151
|
-
// If no exact match, try substring match
|
|
152
|
-
if (!matchingTask) {
|
|
153
|
-
matchingTask = tasks.find(task => task.name.toLowerCase().trim().includes(normalizedSearchTerm) ||
|
|
154
|
-
normalizedSearchTerm.includes(task.name.toLowerCase().trim()));
|
|
155
|
-
}
|
|
156
|
-
// If still no match and there are emoji characters, try matching without emoji
|
|
157
|
-
if (!matchingTask && /[\p{Emoji}]/u.test(normalizedSearchTerm)) {
|
|
158
|
-
matchingTask = this.findMatchingTaskWithoutEmoji(tasks, normalizedSearchTerm);
|
|
159
|
-
}
|
|
160
|
-
return matchingTask || null;
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* Find matching task with emoji characters removed
|
|
164
|
-
* @param tasks List of tasks to search
|
|
165
|
-
* @param searchTerm Search term (with emoji)
|
|
166
|
-
* @returns Matching task or null
|
|
167
|
-
*/
|
|
168
|
-
findMatchingTaskWithoutEmoji(tasks, searchTerm) {
|
|
169
|
-
// Remove emoji and try again (simple approximation)
|
|
170
|
-
const withoutEmoji = searchTerm.replace(/[\p{Emoji}]/gu, '').trim();
|
|
171
|
-
return tasks.find(task => {
|
|
172
|
-
const taskNameWithoutEmoji = task.name.toLowerCase().replace(/[\p{Emoji}]/gu, '').trim();
|
|
173
|
-
return taskNameWithoutEmoji === withoutEmoji ||
|
|
174
|
-
taskNameWithoutEmoji.includes(withoutEmoji) ||
|
|
175
|
-
withoutEmoji.includes(taskNameWithoutEmoji);
|
|
176
|
-
}) || null;
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Create a new task in the specified list
|
|
180
|
-
* @param listId The ID of the list to create the task in
|
|
181
|
-
* @param taskData The data for the new task
|
|
182
|
-
* @returns The created task
|
|
183
|
-
*/
|
|
184
|
-
async createTask(listId, taskData) {
|
|
185
|
-
this.logOperation('createTask', { listId, ...taskData });
|
|
186
|
-
try {
|
|
187
|
-
return await this.makeRequest(async () => {
|
|
188
|
-
const response = await this.client.post(`/list/${listId}/task`, taskData);
|
|
189
|
-
return response.data;
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
throw this.handleError(error, `Failed to create task in list ${listId}`);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Get all tasks in a list with optional filtering
|
|
198
|
-
* @param listId The ID of the list to get tasks from
|
|
199
|
-
* @param filters Optional filters to apply
|
|
200
|
-
* @returns List of tasks matching the filters
|
|
201
|
-
*/
|
|
202
|
-
async getTasks(listId, filters = {}) {
|
|
203
|
-
this.logOperation('getTasks', { listId, filters });
|
|
204
|
-
try {
|
|
205
|
-
return await this.makeRequest(async () => {
|
|
206
|
-
const params = this.buildTaskFilterParams(filters);
|
|
207
|
-
const response = await this.client.get(`/list/${listId}/task?${params.toString()}`);
|
|
208
|
-
return response.data.tasks;
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
catch (error) {
|
|
212
|
-
throw this.handleError(error, `Failed to get tasks from list ${listId}`);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Get a specific task by ID
|
|
217
|
-
* @param taskId The ID of the task to retrieve
|
|
218
|
-
* @returns The task details
|
|
219
|
-
*/
|
|
220
|
-
async getTask(taskId) {
|
|
221
|
-
this.logOperation('getTask', { taskId });
|
|
222
|
-
try {
|
|
223
|
-
return await this.makeRequest(async () => {
|
|
224
|
-
const response = await this.client.get(`/task/${taskId}`);
|
|
225
|
-
return response.data;
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
catch (error) {
|
|
229
|
-
throw this.handleError(error, `Failed to get task ${taskId}`);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Get subtasks of a specific task
|
|
234
|
-
* @param taskId The ID of the parent task
|
|
235
|
-
* @returns Array of subtask details
|
|
236
|
-
*/
|
|
237
|
-
async getSubtasks(taskId) {
|
|
238
|
-
this.logOperation('getSubtasks', { taskId });
|
|
239
|
-
try {
|
|
240
|
-
return await this.makeRequest(async () => {
|
|
241
|
-
// First, get the task to get its list ID
|
|
242
|
-
const task = await this.getTask(taskId);
|
|
243
|
-
const listId = task.list.id;
|
|
244
|
-
// Then get all tasks from the list
|
|
245
|
-
const allTasks = await this.getTasks(listId, { subtasks: true });
|
|
246
|
-
// Filter tasks that have the specified task as parent
|
|
247
|
-
return allTasks.filter(t => t.parent === taskId || t.top_level_parent === taskId);
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
catch (error) {
|
|
251
|
-
throw this.handleError(error, `Failed to get subtasks of task ${taskId}`);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Get a specific task by its custom ID
|
|
256
|
-
* @param customTaskId The custom ID of the task (e.g., "DEV-1234")
|
|
257
|
-
* @param listId Optional list ID to search within for better performance
|
|
258
|
-
* @returns The task details
|
|
259
|
-
*/
|
|
260
|
-
async getTaskByCustomId(customTaskId, listId) {
|
|
261
|
-
this.logOperation('getTaskByCustomId', { customTaskId, listId });
|
|
262
|
-
try {
|
|
263
|
-
return await this.makeRequest(async () => {
|
|
264
|
-
// Build query with custom_task_ids=true and team_id
|
|
265
|
-
const params = new URLSearchParams({
|
|
266
|
-
custom_task_ids: 'true',
|
|
267
|
-
team_id: this.teamId
|
|
268
|
-
});
|
|
269
|
-
// Use the ClickUp API endpoint for retrieving tasks by ID
|
|
270
|
-
// With custom_task_ids=true parameter, it will treat the ID as a custom ID
|
|
271
|
-
const response = await this.client.get(`/task/${customTaskId}?${params.toString()}`);
|
|
272
|
-
return response.data;
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
catch (error) {
|
|
276
|
-
// Enhance error message for custom ID lookups
|
|
277
|
-
if (error?.response?.status === 404) {
|
|
278
|
-
throw this.handleError(error, `Task with custom ID ${customTaskId} not found`);
|
|
279
|
-
}
|
|
280
|
-
throw this.handleError(error, `Failed to get task with custom ID ${customTaskId}`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* Update an existing task
|
|
285
|
-
* @param taskId ID of the task to update
|
|
286
|
-
* @param updateData Data to update on the task
|
|
287
|
-
* @returns The updated task
|
|
288
|
-
*/
|
|
289
|
-
async updateTask(taskId, updateData) {
|
|
290
|
-
this.logOperation('updateTask', { taskId, ...updateData });
|
|
291
|
-
try {
|
|
292
|
-
return await this.makeRequest(async () => {
|
|
293
|
-
const response = await this.client.put(`/task/${taskId}`, updateData);
|
|
294
|
-
return response.data;
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
catch (error) {
|
|
298
|
-
throw this.handleError(error, `Failed to update task ${taskId}`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* Delete a task
|
|
303
|
-
* @param taskId The ID of the task to delete
|
|
304
|
-
* @returns Success indicator
|
|
305
|
-
*/
|
|
306
|
-
async deleteTask(taskId) {
|
|
307
|
-
this.logOperation('deleteTask', { taskId });
|
|
308
|
-
try {
|
|
309
|
-
await this.makeRequest(async () => {
|
|
310
|
-
await this.client.delete(`/task/${taskId}`);
|
|
311
|
-
});
|
|
312
|
-
return {
|
|
313
|
-
success: true
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
catch (error) {
|
|
317
|
-
throw this.handleError(error, `Failed to delete task ${taskId}`);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
/**
|
|
321
|
-
* Find a task by name within a list
|
|
322
|
-
* @param listId The list ID to search within
|
|
323
|
-
* @param taskName The name of the task to find
|
|
324
|
-
* @returns The task if found, otherwise null
|
|
325
|
-
*/
|
|
326
|
-
async findTaskByName(listId, taskName) {
|
|
327
|
-
this.logOperation('findTaskByName', { listId, taskName });
|
|
328
|
-
try {
|
|
329
|
-
const tasks = await this.getTasks(listId);
|
|
330
|
-
return this.findMatchingTask(tasks, taskName);
|
|
331
|
-
}
|
|
332
|
-
catch (error) {
|
|
333
|
-
throw this.handleError(error, `Failed to find task by name: ${error instanceof Error ? error.message : String(error)}`);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* Move a task to a different list
|
|
338
|
-
* @param taskId The ID of the task to move
|
|
339
|
-
* @param destinationListId The ID of the list to move the task to
|
|
340
|
-
* @returns The updated task
|
|
341
|
-
*/
|
|
342
|
-
async moveTask(taskId, destinationListId) {
|
|
343
|
-
this.logOperation('moveTask', { taskId, destinationListId });
|
|
344
|
-
try {
|
|
345
|
-
// First, get both the task and list info in parallel to save time
|
|
346
|
-
const [originalTask, destinationList] = await Promise.all([
|
|
347
|
-
this.getTask(taskId),
|
|
348
|
-
this.listService.getList(destinationListId)
|
|
349
|
-
]);
|
|
350
|
-
const currentStatus = originalTask.status?.status;
|
|
351
|
-
const availableStatuses = destinationList.statuses?.map(s => s.status) || [];
|
|
352
|
-
// Determine the appropriate status for the destination list
|
|
353
|
-
let newStatus = availableStatuses.includes(currentStatus || '')
|
|
354
|
-
? currentStatus // Keep the same status if available in destination list
|
|
355
|
-
: destinationList.statuses?.[0]?.status; // Otherwise use the default (first) status
|
|
356
|
-
// Prepare the task data for the new list
|
|
357
|
-
const taskData = this.extractTaskData(originalTask);
|
|
358
|
-
taskData.status = newStatus;
|
|
359
|
-
// Create new task and delete old one in a single makeRequest call
|
|
360
|
-
return await this.makeRequest(async () => {
|
|
361
|
-
// First create the new task
|
|
362
|
-
const response = await this.client.post(`/list/${destinationListId}/task`, taskData);
|
|
363
|
-
// Then delete the original task
|
|
364
|
-
await this.client.delete(`/task/${taskId}`);
|
|
365
|
-
// Add a property to indicate the task was moved
|
|
366
|
-
const newTask = {
|
|
367
|
-
...response.data,
|
|
368
|
-
moved: true,
|
|
369
|
-
originalId: taskId
|
|
370
|
-
};
|
|
371
|
-
return newTask;
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
catch (error) {
|
|
375
|
-
throw this.handleError(error, 'Failed to move task');
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* Create a duplicate of an existing task
|
|
380
|
-
* @param taskId The ID of the task to duplicate
|
|
381
|
-
* @param listId Optional destination list ID (defaults to the same list)
|
|
382
|
-
* @returns The newly created duplicate task
|
|
383
|
-
*/
|
|
384
|
-
async duplicateTask(taskId, listId) {
|
|
385
|
-
this.logOperation('duplicateTask', { taskId, listId });
|
|
386
|
-
try {
|
|
387
|
-
// Get the original task to duplicate
|
|
388
|
-
const originalTask = await this.getTask(taskId);
|
|
389
|
-
// Create a copy of the task data with "(copy)" appended to the name
|
|
390
|
-
const newTaskData = this.extractTaskData(originalTask, `${originalTask.name} (copy)`);
|
|
391
|
-
// Create the new task in the specified list or original list
|
|
392
|
-
const targetListId = listId || originalTask.list.id;
|
|
393
|
-
return await this.createTask(targetListId, newTaskData);
|
|
394
|
-
}
|
|
395
|
-
catch (error) {
|
|
396
|
-
throw this.handleError(error, 'Failed to duplicate task');
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
/**
|
|
400
|
-
* Get all comments for a task
|
|
401
|
-
*
|
|
402
|
-
* @param taskId ID of the task to get comments for
|
|
403
|
-
* @param start Optional pagination start
|
|
404
|
-
* @param startId Optional comment ID to start from
|
|
405
|
-
* @returns Array of task comments
|
|
406
|
-
*/
|
|
407
|
-
async getTaskComments(taskId, start, startId) {
|
|
408
|
-
this.logOperation('getTaskComments', { taskId, start, startId });
|
|
409
|
-
try {
|
|
410
|
-
// Build query parameters for pagination
|
|
411
|
-
const queryParams = new URLSearchParams();
|
|
412
|
-
if (start !== undefined) {
|
|
413
|
-
queryParams.append('start', start.toString());
|
|
414
|
-
}
|
|
415
|
-
if (startId) {
|
|
416
|
-
queryParams.append('start_id', startId);
|
|
417
|
-
}
|
|
418
|
-
const queryString = queryParams.toString() ? `?${queryParams.toString()}` : '';
|
|
419
|
-
const response = await this.client.get(`/task/${taskId}/comment${queryString}`);
|
|
420
|
-
return response.data.comments || [];
|
|
421
|
-
}
|
|
422
|
-
catch (error) {
|
|
423
|
-
throw this.handleError(error, 'Failed to get task comments');
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
/**
|
|
427
|
-
* Create a comment on a task
|
|
428
|
-
*
|
|
429
|
-
* @param taskId ID of the task to comment on
|
|
430
|
-
* @param commentText Text content of the comment
|
|
431
|
-
* @param notifyAll Whether to notify all assignees
|
|
432
|
-
* @param assignee Optional user ID to assign the comment to
|
|
433
|
-
* @returns The created comment
|
|
434
|
-
*/
|
|
435
|
-
async createTaskComment(taskId, commentText, notifyAll = false, assignee) {
|
|
436
|
-
this.logOperation('createTaskComment', { taskId, commentText, notifyAll, assignee });
|
|
437
|
-
try {
|
|
438
|
-
const payload = {
|
|
439
|
-
comment_text: commentText,
|
|
440
|
-
notify_all: notifyAll
|
|
441
|
-
};
|
|
442
|
-
if (assignee) {
|
|
443
|
-
payload.assignee = assignee;
|
|
444
|
-
}
|
|
445
|
-
// Make the request directly without using makeRequest for better error handling
|
|
446
|
-
const response = await this.client.post(`/task/${taskId}/comment`, payload);
|
|
447
|
-
// Handle different response formats from ClickUp API
|
|
448
|
-
if (response.data) {
|
|
449
|
-
if (response.data.comment) {
|
|
450
|
-
// Standard format: { comment: ClickUpComment }
|
|
451
|
-
return response.data.comment;
|
|
452
|
-
}
|
|
453
|
-
else if (response.data.id && (response.data.comment_text || response.data.comment)) {
|
|
454
|
-
// Direct format: the comment object itself
|
|
455
|
-
return response.data;
|
|
456
|
-
}
|
|
457
|
-
else {
|
|
458
|
-
// Fallback: construct a minimal valid comment object
|
|
459
|
-
return {
|
|
460
|
-
id: response.data.id || `custom-${Date.now()}`,
|
|
461
|
-
comment: response.data.comment || commentText,
|
|
462
|
-
comment_text: response.data.comment_text || commentText,
|
|
463
|
-
user: response.data.user || { id: 0, username: 'Unknown', email: '', color: '' },
|
|
464
|
-
date: response.data.date || new Date().toISOString(),
|
|
465
|
-
resolved: false
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
throw new Error('Invalid response from ClickUp API');
|
|
470
|
-
}
|
|
471
|
-
catch (error) {
|
|
472
|
-
// Check if comment might have been created despite error
|
|
473
|
-
if (error.response?.status === 200 || error.response?.status === 201) {
|
|
474
|
-
// Try to construct a comment object from what we know
|
|
475
|
-
return {
|
|
476
|
-
id: `fallback-${Date.now()}`,
|
|
477
|
-
comment: commentText,
|
|
478
|
-
comment_text: commentText,
|
|
479
|
-
user: { id: 0, username: 'Unknown', email: '', color: '' },
|
|
480
|
-
date: new Date().toISOString(),
|
|
481
|
-
resolved: false
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
throw this.handleError(error, 'Failed to create task comment');
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
/**
|
|
488
|
-
* Validate that a list exists
|
|
489
|
-
*
|
|
490
|
-
* @param listId ID of the list to validate
|
|
491
|
-
* @throws ClickUpServiceError if the list doesn't exist
|
|
492
|
-
*/
|
|
493
|
-
async validateListExists(listId) {
|
|
494
|
-
this.logOperation('validateListExists', { listId });
|
|
495
|
-
try {
|
|
496
|
-
const list = await this.listService.getList(listId);
|
|
497
|
-
if (!list) {
|
|
498
|
-
throw new ClickUpServiceError(`List not found: ${listId}`, ErrorCode.NOT_FOUND, { listId });
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
catch (error) {
|
|
502
|
-
if (error instanceof ClickUpServiceError) {
|
|
503
|
-
throw error;
|
|
504
|
-
}
|
|
505
|
-
throw new ClickUpServiceError(`Failed to validate list existence: ${error instanceof Error ? error.message : String(error)}`, ErrorCode.UNKNOWN, { listId });
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
/**
|
|
509
|
-
* Upload a file attachment to a ClickUp task
|
|
510
|
-
* @param taskId The ID of the task to attach the file to
|
|
511
|
-
* @param fileData The file data as a Buffer
|
|
512
|
-
* @param fileName The name of the file
|
|
513
|
-
* @returns Promise resolving to the attachment response from ClickUp
|
|
514
|
-
*/
|
|
515
|
-
async uploadTaskAttachment(taskId, fileData, fileName) {
|
|
516
|
-
this.logOperation('uploadTaskAttachment', { taskId, fileName, fileSize: fileData.length });
|
|
517
|
-
try {
|
|
518
|
-
return await this.makeRequest(async () => {
|
|
519
|
-
// Create FormData for multipart/form-data upload
|
|
520
|
-
const FormData = (await import('form-data')).default;
|
|
521
|
-
const formData = new FormData();
|
|
522
|
-
// Add the file to the form data
|
|
523
|
-
formData.append('attachment', fileData, {
|
|
524
|
-
filename: fileName,
|
|
525
|
-
contentType: 'application/octet-stream' // Let ClickUp determine the content type
|
|
526
|
-
});
|
|
527
|
-
// Use the raw axios client for this request since we need to handle FormData
|
|
528
|
-
const response = await this.client.post(`/task/${taskId}/attachment`, formData, {
|
|
529
|
-
headers: {
|
|
530
|
-
...formData.getHeaders(),
|
|
531
|
-
'Authorization': this.apiKey
|
|
532
|
-
}
|
|
533
|
-
});
|
|
534
|
-
return response.data;
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
catch (error) {
|
|
538
|
-
throw this.handleError(error, `Failed to upload attachment to task ${taskId}`);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
/**
|
|
542
|
-
* Upload a file attachment to a ClickUp task from a URL
|
|
543
|
-
* @param taskId The ID of the task to attach the file to
|
|
544
|
-
* @param fileUrl The URL of the file to download and attach
|
|
545
|
-
* @param fileName Optional file name (if not provided, it will be extracted from the URL)
|
|
546
|
-
* @param authHeader Optional authorization header for the URL
|
|
547
|
-
* @returns Promise resolving to the attachment response from ClickUp
|
|
548
|
-
*/
|
|
549
|
-
async uploadTaskAttachmentFromUrl(taskId, fileUrl, fileName, authHeader) {
|
|
550
|
-
this.logOperation('uploadTaskAttachmentFromUrl', { taskId, fileUrl, fileName });
|
|
551
|
-
try {
|
|
552
|
-
return await this.makeRequest(async () => {
|
|
553
|
-
// Import required modules
|
|
554
|
-
const axios = (await import('axios')).default;
|
|
555
|
-
const FormData = (await import('form-data')).default;
|
|
556
|
-
// Download the file from the URL
|
|
557
|
-
const headers = {};
|
|
558
|
-
if (authHeader) {
|
|
559
|
-
headers['Authorization'] = authHeader;
|
|
560
|
-
}
|
|
561
|
-
const response = await axios.get(fileUrl, {
|
|
562
|
-
responseType: 'arraybuffer',
|
|
563
|
-
headers
|
|
564
|
-
});
|
|
565
|
-
// Extract filename from URL if not provided
|
|
566
|
-
const actualFileName = fileName || fileUrl.split('/').pop() || 'downloaded-file';
|
|
567
|
-
// Create FormData for multipart/form-data upload
|
|
568
|
-
const formData = new FormData();
|
|
569
|
-
// Add the file to the form data
|
|
570
|
-
formData.append('attachment', Buffer.from(response.data), {
|
|
571
|
-
filename: actualFileName,
|
|
572
|
-
contentType: 'application/octet-stream'
|
|
573
|
-
});
|
|
574
|
-
// Upload the file to ClickUp
|
|
575
|
-
const uploadResponse = await this.client.post(`/task/${taskId}/attachment`, formData, {
|
|
576
|
-
headers: {
|
|
577
|
-
...formData.getHeaders(),
|
|
578
|
-
'Authorization': this.apiKey
|
|
579
|
-
}
|
|
580
|
-
});
|
|
581
|
-
return uploadResponse.data;
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
catch (error) {
|
|
585
|
-
throw this.handleError(error, `Failed to upload attachment from URL to task ${taskId}`);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Format task data for summary view
|
|
590
|
-
* @param task The task to format
|
|
591
|
-
* @returns TaskSummary object
|
|
592
|
-
*/
|
|
593
|
-
formatTaskSummary(task) {
|
|
594
|
-
return {
|
|
595
|
-
id: task.id,
|
|
596
|
-
name: task.name,
|
|
597
|
-
status: task.status.status,
|
|
598
|
-
list: {
|
|
599
|
-
id: task.list.id,
|
|
600
|
-
name: task.list.name
|
|
601
|
-
},
|
|
602
|
-
due_date: task.due_date,
|
|
603
|
-
url: task.url,
|
|
604
|
-
priority: this.extractPriorityValue(task),
|
|
605
|
-
tags: task.tags.map(tag => ({
|
|
606
|
-
name: tag.name,
|
|
607
|
-
tag_bg: tag.tag_bg,
|
|
608
|
-
tag_fg: tag.tag_fg
|
|
609
|
-
}))
|
|
610
|
-
};
|
|
611
|
-
}
|
|
612
|
-
/**
|
|
613
|
-
* Estimates token count for a task in JSON format
|
|
614
|
-
* @param task ClickUp task
|
|
615
|
-
* @returns Estimated token count
|
|
616
|
-
*/
|
|
617
|
-
estimateTaskTokens(task) {
|
|
618
|
-
return estimateTokensFromObject(task);
|
|
619
|
-
}
|
|
620
|
-
/**
|
|
621
|
-
* Get filtered tasks across the entire team/workspace using tags and other filters
|
|
622
|
-
* @param filters Task filters to apply including tags, list/folder/space filtering
|
|
623
|
-
* @returns Either a DetailedTaskResponse or WorkspaceTasksResponse depending on detail_level
|
|
624
|
-
*/
|
|
625
|
-
async getWorkspaceTasks(filters = {}) {
|
|
626
|
-
try {
|
|
627
|
-
this.logOperation('getWorkspaceTasks', { filters });
|
|
628
|
-
const params = this.buildTaskFilterParams(filters);
|
|
629
|
-
const response = await this.client.get(`/team/${this.teamId}/task`, {
|
|
630
|
-
params
|
|
631
|
-
});
|
|
632
|
-
const tasks = response.data.tasks;
|
|
633
|
-
const totalCount = tasks.length; // Note: This is just the current page count
|
|
634
|
-
const hasMore = totalCount === 100; // ClickUp returns max 100 tasks per page
|
|
635
|
-
const nextPage = (filters.page || 0) + 1;
|
|
636
|
-
// If the estimated token count exceeds 50,000 or detail_level is 'summary',
|
|
637
|
-
// return summary format for efficiency and to avoid hitting token limits
|
|
638
|
-
const TOKEN_LIMIT = 50000;
|
|
639
|
-
// Estimate tokens for the full response
|
|
640
|
-
let tokensExceedLimit = false;
|
|
641
|
-
if (filters.detail_level !== 'summary' && tasks.length > 0) {
|
|
642
|
-
// We only need to check token count if detailed was requested
|
|
643
|
-
// For summary requests, we always return summary format
|
|
644
|
-
// First check with a sample task - if one task exceeds the limit, we definitely need summary
|
|
645
|
-
const sampleTask = tasks[0];
|
|
646
|
-
// Check if all tasks would exceed the token limit
|
|
647
|
-
const estimatedTokensPerTask = this.estimateTaskTokens(sampleTask);
|
|
648
|
-
const estimatedTotalTokens = estimatedTokensPerTask * tasks.length;
|
|
649
|
-
// Add 10% overhead for the response wrapper
|
|
650
|
-
tokensExceedLimit = estimatedTotalTokens * 1.1 > TOKEN_LIMIT;
|
|
651
|
-
// Double-check with more precise estimation if we're close to the limit
|
|
652
|
-
if (!tokensExceedLimit && estimatedTotalTokens * 1.1 > TOKEN_LIMIT * 0.8) {
|
|
653
|
-
// More precise check - build a representative sample and extrapolate
|
|
654
|
-
tokensExceedLimit = wouldExceedTokenLimit({ tasks, total_count: totalCount, has_more: hasMore, next_page: nextPage }, TOKEN_LIMIT);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
// Determine if we should return summary or detailed based on request and token limit
|
|
658
|
-
const shouldUseSummary = filters.detail_level === 'summary' || tokensExceedLimit;
|
|
659
|
-
this.logOperation('getWorkspaceTasks', {
|
|
660
|
-
totalTasks: tasks.length,
|
|
661
|
-
estimatedTokens: tasks.reduce((count, task) => count + this.estimateTaskTokens(task), 0),
|
|
662
|
-
usingDetailedFormat: !shouldUseSummary,
|
|
663
|
-
requestedFormat: filters.detail_level || 'auto'
|
|
664
|
-
});
|
|
665
|
-
if (shouldUseSummary) {
|
|
666
|
-
return {
|
|
667
|
-
summaries: tasks.map(task => this.formatTaskSummary(task)),
|
|
668
|
-
total_count: totalCount,
|
|
669
|
-
has_more: hasMore,
|
|
670
|
-
next_page: nextPage
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
return {
|
|
674
|
-
tasks,
|
|
675
|
-
total_count: totalCount,
|
|
676
|
-
has_more: hasMore,
|
|
677
|
-
next_page: nextPage
|
|
678
|
-
};
|
|
679
|
-
}
|
|
680
|
-
catch (error) {
|
|
681
|
-
this.logOperation('getWorkspaceTasks', { error: error.message, status: error.response?.status });
|
|
682
|
-
throw this.handleError(error, 'Failed to get workspace tasks');
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
/**
|
|
686
|
-
* Get task summaries for lightweight retrieval
|
|
687
|
-
* @param filters Task filters to apply
|
|
688
|
-
* @returns WorkspaceTasksResponse with task summaries
|
|
689
|
-
*/
|
|
690
|
-
async getTaskSummaries(filters = {}) {
|
|
691
|
-
return this.getWorkspaceTasks({ ...filters, detail_level: 'summary' });
|
|
692
|
-
}
|
|
693
|
-
/**
|
|
694
|
-
* Get detailed task data
|
|
695
|
-
* @param filters Task filters to apply
|
|
696
|
-
* @returns DetailedTaskResponse with full task data
|
|
697
|
-
*/
|
|
698
|
-
async getTaskDetails(filters = {}) {
|
|
699
|
-
return this.getWorkspaceTasks({ ...filters, detail_level: 'detailed' });
|
|
700
|
-
}
|
|
701
|
-
}
|