@alanse/clickup-multi-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +38 -0
- package/LICENSE +21 -0
- package/README.md +470 -0
- package/build/config.js +237 -0
- package/build/index.js +87 -0
- package/build/logger.js +163 -0
- package/build/middleware/security.js +231 -0
- package/build/server.js +288 -0
- package/build/services/clickup/base.js +432 -0
- package/build/services/clickup/bulk.js +180 -0
- package/build/services/clickup/document.js +159 -0
- package/build/services/clickup/folder.js +136 -0
- package/build/services/clickup/index.js +76 -0
- package/build/services/clickup/list.js +191 -0
- package/build/services/clickup/tag.js +239 -0
- package/build/services/clickup/task/index.js +32 -0
- package/build/services/clickup/task/task-attachments.js +105 -0
- package/build/services/clickup/task/task-comments.js +114 -0
- package/build/services/clickup/task/task-core.js +604 -0
- package/build/services/clickup/task/task-custom-fields.js +107 -0
- package/build/services/clickup/task/task-search.js +986 -0
- package/build/services/clickup/task/task-service.js +104 -0
- package/build/services/clickup/task/task-tags.js +113 -0
- package/build/services/clickup/time.js +244 -0
- package/build/services/clickup/types.js +33 -0
- package/build/services/clickup/workspace.js +397 -0
- package/build/services/shared.js +61 -0
- package/build/sse_server.js +277 -0
- package/build/tools/documents.js +489 -0
- package/build/tools/folder.js +331 -0
- package/build/tools/index.js +16 -0
- package/build/tools/list.js +428 -0
- package/build/tools/member.js +106 -0
- package/build/tools/tag.js +833 -0
- package/build/tools/task/attachments.js +357 -0
- package/build/tools/task/attachments.types.js +9 -0
- package/build/tools/task/bulk-operations.js +338 -0
- package/build/tools/task/handlers.js +919 -0
- package/build/tools/task/index.js +30 -0
- package/build/tools/task/main.js +233 -0
- package/build/tools/task/single-operations.js +469 -0
- package/build/tools/task/time-tracking.js +575 -0
- package/build/tools/task/utilities.js +310 -0
- package/build/tools/task/workspace-operations.js +258 -0
- package/build/tools/tool-enhancer.js +37 -0
- package/build/tools/utils.js +12 -0
- package/build/tools/workspace-helper.js +44 -0
- package/build/tools/workspace.js +73 -0
- package/build/utils/color-processor.js +183 -0
- package/build/utils/concurrency-utils.js +248 -0
- package/build/utils/date-utils.js +542 -0
- package/build/utils/resolver-utils.js +135 -0
- package/build/utils/sponsor-service.js +93 -0
- package/build/utils/token-utils.js +49 -0
- package/package.json +77 -0
- package/smithery.yaml +23 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*
|
|
5
|
+
* Base ClickUp Service Class
|
|
6
|
+
*
|
|
7
|
+
* This class provides core functionality for all ClickUp service modules:
|
|
8
|
+
* - Axios client configuration
|
|
9
|
+
* - Rate limiting and request throttling
|
|
10
|
+
* - Error handling
|
|
11
|
+
* - Common request methods
|
|
12
|
+
*/
|
|
13
|
+
import axios from 'axios';
|
|
14
|
+
import { Logger, LogLevel } from '../../logger.js';
|
|
15
|
+
/**
|
|
16
|
+
* Error types for better error handling
|
|
17
|
+
*/
|
|
18
|
+
export var ErrorCode;
|
|
19
|
+
(function (ErrorCode) {
|
|
20
|
+
ErrorCode["RATE_LIMIT"] = "rate_limit_exceeded";
|
|
21
|
+
ErrorCode["NOT_FOUND"] = "resource_not_found";
|
|
22
|
+
ErrorCode["UNAUTHORIZED"] = "unauthorized";
|
|
23
|
+
ErrorCode["VALIDATION"] = "validation_error";
|
|
24
|
+
ErrorCode["SERVER_ERROR"] = "server_error";
|
|
25
|
+
ErrorCode["NETWORK_ERROR"] = "network_error";
|
|
26
|
+
ErrorCode["WORKSPACE_ERROR"] = "workspace_error";
|
|
27
|
+
ErrorCode["INVALID_PARAMETER"] = "invalid_parameter";
|
|
28
|
+
ErrorCode["UNKNOWN"] = "unknown_error";
|
|
29
|
+
})(ErrorCode || (ErrorCode = {}));
|
|
30
|
+
/**
|
|
31
|
+
* Custom error class for ClickUp API errors
|
|
32
|
+
*/
|
|
33
|
+
export class ClickUpServiceError extends Error {
|
|
34
|
+
constructor(message, code = ErrorCode.UNKNOWN, data, status, context) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'ClickUpServiceError';
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.data = data;
|
|
39
|
+
this.status = status;
|
|
40
|
+
this.context = context;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Helper function to safely parse JSON
|
|
45
|
+
* @param data Data to parse
|
|
46
|
+
* @param fallback Optional fallback value if parsing fails
|
|
47
|
+
* @returns Parsed JSON or fallback value
|
|
48
|
+
*/
|
|
49
|
+
function safeJsonParse(data, fallback = undefined) {
|
|
50
|
+
if (typeof data !== 'string') {
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(data);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Base ClickUp service class that handles common functionality
|
|
62
|
+
*/
|
|
63
|
+
export class BaseClickUpService {
|
|
64
|
+
/**
|
|
65
|
+
* Creates an instance of BaseClickUpService.
|
|
66
|
+
* @param apiKey - ClickUp API key for authentication
|
|
67
|
+
* @param teamId - ClickUp team ID for targeting the correct workspace
|
|
68
|
+
* @param baseUrl - Optional custom base URL for the ClickUp API
|
|
69
|
+
*/
|
|
70
|
+
constructor(apiKey, teamId, baseUrl = 'https://api.clickup.com/api/v2') {
|
|
71
|
+
this.defaultRequestSpacing = 600; // Default milliseconds between requests
|
|
72
|
+
this.rateLimit = 100; // Maximum requests per minute (Free Forever plan)
|
|
73
|
+
this.timeout = 65000; // 65 seconds (safely under the 1-minute window)
|
|
74
|
+
this.requestQueue = [];
|
|
75
|
+
this.processingQueue = false;
|
|
76
|
+
this.lastRateLimitReset = 0;
|
|
77
|
+
this.apiKey = apiKey;
|
|
78
|
+
this.teamId = teamId;
|
|
79
|
+
this.requestSpacing = this.defaultRequestSpacing;
|
|
80
|
+
// Create a logger with the actual class name for better context
|
|
81
|
+
const className = this.constructor.name;
|
|
82
|
+
this.logger = new Logger(`ClickUp:${className}`);
|
|
83
|
+
// Configure the Axios client with default settings
|
|
84
|
+
this.client = axios.create({
|
|
85
|
+
baseURL: baseUrl,
|
|
86
|
+
headers: {
|
|
87
|
+
'Authorization': apiKey,
|
|
88
|
+
'Content-Type': 'application/json'
|
|
89
|
+
},
|
|
90
|
+
timeout: this.timeout,
|
|
91
|
+
transformResponse: [
|
|
92
|
+
// Add custom response transformer to handle both JSON and text responses
|
|
93
|
+
(data) => {
|
|
94
|
+
if (!data)
|
|
95
|
+
return data;
|
|
96
|
+
// If it's already an object, return as is
|
|
97
|
+
if (typeof data !== 'string')
|
|
98
|
+
return data;
|
|
99
|
+
// Try to parse as JSON, fall back to raw text if parsing fails
|
|
100
|
+
const parsed = safeJsonParse(data, null);
|
|
101
|
+
return parsed !== null ? parsed : data;
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
});
|
|
105
|
+
this.logger.debug(`Initialized ${className}`, { teamId, baseUrl });
|
|
106
|
+
// Add response interceptor for error handling
|
|
107
|
+
this.client.interceptors.response.use(response => response, error => this.handleAxiosError(error));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Handle errors from Axios requests
|
|
111
|
+
* @private
|
|
112
|
+
* @param error Error from Axios
|
|
113
|
+
* @returns Never - always throws an error
|
|
114
|
+
*/
|
|
115
|
+
handleAxiosError(error) {
|
|
116
|
+
// Determine error details
|
|
117
|
+
const status = error.response?.status;
|
|
118
|
+
const responseData = error.response?.data;
|
|
119
|
+
const errorMsg = responseData?.err || responseData?.error || error.message || 'Unknown API error';
|
|
120
|
+
const path = error.config?.url || 'unknown path';
|
|
121
|
+
// Context object for providing more detailed log information
|
|
122
|
+
const errorContext = {
|
|
123
|
+
path,
|
|
124
|
+
status,
|
|
125
|
+
method: error.config?.method?.toUpperCase() || 'UNKNOWN',
|
|
126
|
+
requestData: error.config?.data ? safeJsonParse(error.config.data, error.config.data) : undefined
|
|
127
|
+
};
|
|
128
|
+
// Pick the appropriate error code based on status
|
|
129
|
+
let code;
|
|
130
|
+
let logMessage;
|
|
131
|
+
let errorMessage;
|
|
132
|
+
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
|
133
|
+
code = ErrorCode.NETWORK_ERROR;
|
|
134
|
+
logMessage = `Request timeout for ${path}`;
|
|
135
|
+
errorMessage = 'Request timed out. Please try again.';
|
|
136
|
+
}
|
|
137
|
+
else if (!error.response) {
|
|
138
|
+
code = ErrorCode.NETWORK_ERROR;
|
|
139
|
+
logMessage = `Network error accessing ${path}: ${error.message}`;
|
|
140
|
+
errorMessage = 'Network error. Please check your connection and try again.';
|
|
141
|
+
}
|
|
142
|
+
else if (status === 429) {
|
|
143
|
+
code = ErrorCode.RATE_LIMIT;
|
|
144
|
+
this.handleRateLimitHeaders(error.response.headers);
|
|
145
|
+
// Calculate time until reset
|
|
146
|
+
const reset = error.response.headers['x-ratelimit-reset'];
|
|
147
|
+
const now = Date.now() / 1000; // Convert to seconds
|
|
148
|
+
const timeToReset = Math.max(0, reset - now);
|
|
149
|
+
const resetMinutes = Math.ceil(timeToReset / 60);
|
|
150
|
+
logMessage = `Rate limit exceeded for ${path}`;
|
|
151
|
+
errorMessage = `Rate limit exceeded. Please wait ${resetMinutes} minute${resetMinutes === 1 ? '' : 's'} before trying again.`;
|
|
152
|
+
// Add more context to the error
|
|
153
|
+
errorContext.rateLimitInfo = {
|
|
154
|
+
limit: error.response.headers['x-ratelimit-limit'],
|
|
155
|
+
remaining: error.response.headers['x-ratelimit-remaining'],
|
|
156
|
+
reset: reset,
|
|
157
|
+
timeToReset: timeToReset
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
else if (status === 401 || status === 403) {
|
|
161
|
+
code = ErrorCode.UNAUTHORIZED;
|
|
162
|
+
logMessage = `Authorization failed for ${path}`;
|
|
163
|
+
errorMessage = 'Authorization failed. Please check your API key.';
|
|
164
|
+
}
|
|
165
|
+
else if (status === 404) {
|
|
166
|
+
code = ErrorCode.NOT_FOUND;
|
|
167
|
+
logMessage = `Resource not found: ${path}`;
|
|
168
|
+
errorMessage = 'Resource not found.';
|
|
169
|
+
}
|
|
170
|
+
else if (status >= 400 && status < 500) {
|
|
171
|
+
code = ErrorCode.VALIDATION;
|
|
172
|
+
logMessage = `Validation error for ${path}: ${errorMsg}`;
|
|
173
|
+
errorMessage = errorMsg;
|
|
174
|
+
}
|
|
175
|
+
else if (status >= 500) {
|
|
176
|
+
code = ErrorCode.SERVER_ERROR;
|
|
177
|
+
logMessage = `ClickUp server error: ${errorMsg}`;
|
|
178
|
+
errorMessage = 'ClickUp server error. Please try again later.';
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
code = ErrorCode.UNKNOWN;
|
|
182
|
+
logMessage = `Unknown API error: ${errorMsg}`;
|
|
183
|
+
errorMessage = 'An unexpected error occurred. Please try again.';
|
|
184
|
+
}
|
|
185
|
+
// Log the error with context
|
|
186
|
+
this.logger.error(logMessage, errorContext);
|
|
187
|
+
// Throw a formatted error with user-friendly message
|
|
188
|
+
throw new ClickUpServiceError(errorMessage, code, error);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Handle rate limit headers from ClickUp API
|
|
192
|
+
* @private
|
|
193
|
+
* @param headers Response headers from ClickUp
|
|
194
|
+
*/
|
|
195
|
+
handleRateLimitHeaders(headers) {
|
|
196
|
+
try {
|
|
197
|
+
// Parse the rate limit headers
|
|
198
|
+
const limit = headers['x-ratelimit-limit'];
|
|
199
|
+
const remaining = headers['x-ratelimit-remaining'];
|
|
200
|
+
const reset = headers['x-ratelimit-reset'];
|
|
201
|
+
// Only log if we're getting close to the limit
|
|
202
|
+
if (remaining < limit * 0.2) {
|
|
203
|
+
this.logger.warn('Approaching rate limit', { remaining, limit, reset });
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
this.logger.debug('Rate limit status', { remaining, limit, reset });
|
|
207
|
+
}
|
|
208
|
+
if (reset) {
|
|
209
|
+
this.lastRateLimitReset = reset;
|
|
210
|
+
// If reset is in the future, calculate a safe request spacing
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
const resetTime = reset * 1000; // convert to milliseconds
|
|
213
|
+
const timeToReset = Math.max(0, resetTime - now);
|
|
214
|
+
// Proactively adjust spacing when remaining requests get low
|
|
215
|
+
// This helps avoid hitting rate limits in the first place
|
|
216
|
+
if (remaining < limit * 0.3) {
|
|
217
|
+
// More aggressive spacing when close to limit
|
|
218
|
+
let safeSpacing;
|
|
219
|
+
if (remaining <= 5) {
|
|
220
|
+
// Very aggressive spacing for last few requests
|
|
221
|
+
safeSpacing = Math.ceil((timeToReset / remaining) * 2);
|
|
222
|
+
// Start processing in queue mode preemptively
|
|
223
|
+
if (!this.processingQueue) {
|
|
224
|
+
this.logger.info('Preemptively switching to queue mode (low remaining requests)', {
|
|
225
|
+
remaining,
|
|
226
|
+
limit
|
|
227
|
+
});
|
|
228
|
+
this.processingQueue = true;
|
|
229
|
+
this.processQueue().catch(err => {
|
|
230
|
+
this.logger.error('Error processing request queue', err);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (remaining <= 20) {
|
|
235
|
+
// More aggressive spacing
|
|
236
|
+
safeSpacing = Math.ceil((timeToReset / remaining) * 1.5);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// Standard safe spacing with buffer
|
|
240
|
+
safeSpacing = Math.ceil((timeToReset / remaining) * 1.1);
|
|
241
|
+
}
|
|
242
|
+
// Apply updated spacing, but with a reasonable maximum
|
|
243
|
+
const maxSpacing = 5000; // 5 seconds max spacing
|
|
244
|
+
const adjustedSpacing = Math.min(safeSpacing, maxSpacing);
|
|
245
|
+
// Only adjust if it's greater than our current spacing
|
|
246
|
+
if (adjustedSpacing > this.requestSpacing) {
|
|
247
|
+
this.logger.debug(`Adjusting request spacing: ${this.requestSpacing}ms → ${adjustedSpacing}ms`, {
|
|
248
|
+
remaining,
|
|
249
|
+
timeToReset
|
|
250
|
+
});
|
|
251
|
+
this.requestSpacing = adjustedSpacing;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
this.logger.warn('Failed to parse rate limit headers', error);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Process the request queue, respecting rate limits by spacing out requests
|
|
262
|
+
* @private
|
|
263
|
+
*/
|
|
264
|
+
async processQueue() {
|
|
265
|
+
if (this.requestQueue.length === 0) {
|
|
266
|
+
this.logger.debug('Queue empty, exiting queue processing mode');
|
|
267
|
+
this.processingQueue = false;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const queueLength = this.requestQueue.length;
|
|
271
|
+
this.logger.debug(`Processing request queue (${queueLength} items)`);
|
|
272
|
+
const startTime = Date.now();
|
|
273
|
+
try {
|
|
274
|
+
// Take the first request from the queue
|
|
275
|
+
const request = this.requestQueue.shift();
|
|
276
|
+
if (request) {
|
|
277
|
+
// Adjust delay based on queue size
|
|
278
|
+
// Longer delays for bigger queues to prevent overwhelming the API
|
|
279
|
+
let delay = this.requestSpacing;
|
|
280
|
+
if (queueLength > 20) {
|
|
281
|
+
delay = this.requestSpacing * 2;
|
|
282
|
+
}
|
|
283
|
+
else if (queueLength > 10) {
|
|
284
|
+
delay = this.requestSpacing * 1.5;
|
|
285
|
+
}
|
|
286
|
+
// Wait for the calculated delay
|
|
287
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
288
|
+
// Run the request
|
|
289
|
+
await request();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (error instanceof ClickUpServiceError && error.code === ErrorCode.RATE_LIMIT) {
|
|
294
|
+
// If we still hit rate limits, increase the spacing
|
|
295
|
+
this.requestSpacing = Math.min(this.requestSpacing * 1.5, 10000); // Max 10s
|
|
296
|
+
this.logger.warn(`Rate limit hit during queue processing, increasing delay to ${this.requestSpacing}ms`);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
this.logger.error('Error executing queued request', error);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
const duration = Date.now() - startTime;
|
|
304
|
+
this.logger.trace(`Queue item processed in ${duration}ms, ${this.requestQueue.length} items remaining`);
|
|
305
|
+
// Continue processing the queue after the calculated delay
|
|
306
|
+
setTimeout(() => this.processQueue(), this.requestSpacing);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Makes an API request with rate limiting.
|
|
311
|
+
* @protected
|
|
312
|
+
* @param fn - Function that executes the API request
|
|
313
|
+
* @returns Promise that resolves with the result of the API request
|
|
314
|
+
*/
|
|
315
|
+
async makeRequest(fn) {
|
|
316
|
+
// If we're being rate limited, queue the request rather than executing immediately
|
|
317
|
+
if (this.processingQueue) {
|
|
318
|
+
const queuePosition = this.requestQueue.length + 1;
|
|
319
|
+
const estimatedWaitTime = Math.ceil((queuePosition * this.requestSpacing) / 1000);
|
|
320
|
+
this.logger.info('Request queued due to rate limiting', {
|
|
321
|
+
queuePosition,
|
|
322
|
+
estimatedWaitSeconds: estimatedWaitTime,
|
|
323
|
+
currentSpacing: this.requestSpacing
|
|
324
|
+
});
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
this.requestQueue.push(async () => {
|
|
327
|
+
try {
|
|
328
|
+
const result = await fn();
|
|
329
|
+
resolve(result);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
// Enhance error message with queue context if it's a rate limit error
|
|
333
|
+
if (error instanceof ClickUpServiceError && error.code === ErrorCode.RATE_LIMIT) {
|
|
334
|
+
const enhancedError = new ClickUpServiceError(`${error.message} (Request was queued at position ${queuePosition})`, error.code, error.data);
|
|
335
|
+
reject(enhancedError);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
reject(error);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// Track request metadata
|
|
345
|
+
let requestMethod = 'unknown';
|
|
346
|
+
let requestPath = 'unknown';
|
|
347
|
+
let requestData = undefined;
|
|
348
|
+
// Set up interceptor to capture request details
|
|
349
|
+
const requestInterceptorId = this.client.interceptors.request.use((config) => {
|
|
350
|
+
// Capture request metadata
|
|
351
|
+
requestMethod = config.method?.toUpperCase() || 'unknown';
|
|
352
|
+
requestPath = config.url || 'unknown';
|
|
353
|
+
requestData = config.data;
|
|
354
|
+
return config;
|
|
355
|
+
});
|
|
356
|
+
const startTime = Date.now();
|
|
357
|
+
try {
|
|
358
|
+
// Execute the request function
|
|
359
|
+
const result = await fn();
|
|
360
|
+
// Debug log for successful requests with timing information
|
|
361
|
+
const duration = Date.now() - startTime;
|
|
362
|
+
this.logger.debug(`Request completed successfully in ${duration}ms`, {
|
|
363
|
+
method: requestMethod,
|
|
364
|
+
path: requestPath,
|
|
365
|
+
duration,
|
|
366
|
+
responseType: result ? typeof result : 'undefined'
|
|
367
|
+
});
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
// If we hit a rate limit, start processing the queue
|
|
372
|
+
if (error instanceof ClickUpServiceError && error.code === ErrorCode.RATE_LIMIT) {
|
|
373
|
+
this.logger.warn('Rate limit reached, switching to queue mode', {
|
|
374
|
+
reset: this.lastRateLimitReset,
|
|
375
|
+
queueLength: this.requestQueue.length
|
|
376
|
+
});
|
|
377
|
+
if (!this.processingQueue) {
|
|
378
|
+
this.processingQueue = true;
|
|
379
|
+
this.processQueue().catch(err => {
|
|
380
|
+
this.logger.error('Error processing request queue', err);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
// Queue this failed request and return a promise that will resolve when it's retried
|
|
384
|
+
return new Promise((resolve, reject) => {
|
|
385
|
+
this.requestQueue.push(async () => {
|
|
386
|
+
try {
|
|
387
|
+
const result = await fn();
|
|
388
|
+
resolve(result);
|
|
389
|
+
}
|
|
390
|
+
catch (retryError) {
|
|
391
|
+
reject(retryError);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
// For other errors, just throw
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
// Always remove the interceptor
|
|
401
|
+
this.client.interceptors.request.eject(requestInterceptorId);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Gets the ClickUp team ID associated with this service instance
|
|
406
|
+
* @returns The team ID
|
|
407
|
+
*/
|
|
408
|
+
getTeamId() {
|
|
409
|
+
return this.teamId;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Helper method to log API operations
|
|
413
|
+
* @protected
|
|
414
|
+
* @param operation - Name of the operation being performed
|
|
415
|
+
* @param details - Details about the operation
|
|
416
|
+
*/
|
|
417
|
+
logOperation(operation, details) {
|
|
418
|
+
this.logger.info(`Operation: ${operation}`, details);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Log detailed information about a request (path and payload)
|
|
422
|
+
* For trace level logging only
|
|
423
|
+
*/
|
|
424
|
+
traceRequest(method, url, data) {
|
|
425
|
+
if (this.logger.isLevelEnabled(LogLevel.TRACE)) {
|
|
426
|
+
this.logger.trace(`${method} ${url}`, {
|
|
427
|
+
payload: data,
|
|
428
|
+
teamId: this.teamId
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*
|
|
5
|
+
* ClickUp Bulk Service
|
|
6
|
+
*
|
|
7
|
+
* Enhanced implementation for bulk operations that leverages the existing single-operation methods.
|
|
8
|
+
* This approach reduces code duplication while offering powerful concurrency management.
|
|
9
|
+
*/
|
|
10
|
+
import { Logger } from '../../logger.js';
|
|
11
|
+
import { processBatch } from '../../utils/concurrency-utils.js';
|
|
12
|
+
import { ClickUpServiceError, ErrorCode } from './base.js';
|
|
13
|
+
import { clickUpServices } from '../shared.js';
|
|
14
|
+
import { findListIDByName } from '../../tools/list.js';
|
|
15
|
+
// Create logger instance
|
|
16
|
+
const logger = new Logger('BulkService');
|
|
17
|
+
/**
|
|
18
|
+
* Service for performing bulk operations in ClickUp
|
|
19
|
+
*/
|
|
20
|
+
export class BulkService {
|
|
21
|
+
/**
|
|
22
|
+
* Create a new bulk service
|
|
23
|
+
* @param taskService ClickUp Task Service instance
|
|
24
|
+
*/
|
|
25
|
+
constructor(taskService) {
|
|
26
|
+
this.taskService = taskService;
|
|
27
|
+
logger.info('BulkService initialized');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Create multiple tasks in a list efficiently
|
|
31
|
+
*
|
|
32
|
+
* @param listId ID of the list to create tasks in
|
|
33
|
+
* @param tasks Array of task data
|
|
34
|
+
* @param options Batch processing options
|
|
35
|
+
* @returns Results containing successful and failed tasks
|
|
36
|
+
*/
|
|
37
|
+
async createTasks(listId, tasks, options) {
|
|
38
|
+
logger.info(`Creating ${tasks.length} tasks in list ${listId}`, {
|
|
39
|
+
batchSize: options?.batchSize,
|
|
40
|
+
concurrency: options?.concurrency
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
// First validate that the list exists - do this once for all tasks
|
|
44
|
+
await this.taskService.validateListExists(listId);
|
|
45
|
+
// Process the tasks in batches
|
|
46
|
+
return await processBatch(tasks, (task, index) => {
|
|
47
|
+
logger.debug(`Creating task ${index + 1}/${tasks.length}`, {
|
|
48
|
+
taskName: task.name
|
|
49
|
+
});
|
|
50
|
+
// Reuse the single-task creation method
|
|
51
|
+
return this.taskService.createTask(listId, task);
|
|
52
|
+
}, options);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
logger.error(`Failed to create tasks in bulk`, {
|
|
56
|
+
listId,
|
|
57
|
+
taskCount: tasks.length,
|
|
58
|
+
error: error instanceof Error ? error.message : String(error)
|
|
59
|
+
});
|
|
60
|
+
throw new ClickUpServiceError(`Failed to create tasks in bulk: ${error instanceof Error ? error.message : String(error)}`, error instanceof ClickUpServiceError ? error.code : ErrorCode.UNKNOWN, { listId, taskCount: tasks.length });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Find task by name within a specific list
|
|
65
|
+
*/
|
|
66
|
+
async findTaskInList(taskName, listName) {
|
|
67
|
+
try {
|
|
68
|
+
const result = await this.taskService.findTasks({
|
|
69
|
+
taskName,
|
|
70
|
+
listName,
|
|
71
|
+
allowMultipleMatches: false,
|
|
72
|
+
useSmartDisambiguation: true,
|
|
73
|
+
includeFullDetails: false
|
|
74
|
+
});
|
|
75
|
+
if (!result || Array.isArray(result)) {
|
|
76
|
+
throw new ClickUpServiceError(`Task "${taskName}" not found in list "${listName}"`, ErrorCode.NOT_FOUND);
|
|
77
|
+
}
|
|
78
|
+
logger.info(`Task "${taskName}" found with ID: ${result.id}`);
|
|
79
|
+
return result.id;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// Enhance the error message
|
|
83
|
+
if (error instanceof ClickUpServiceError) {
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
throw new ClickUpServiceError(`Error finding task "${taskName}" in list "${listName}": ${error instanceof Error ? error.message : String(error)}`, ErrorCode.UNKNOWN);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve task ID using provided identifiers
|
|
91
|
+
*/
|
|
92
|
+
async resolveTaskId(task) {
|
|
93
|
+
const { taskId, taskName, listName, customTaskId } = task;
|
|
94
|
+
if (taskId) {
|
|
95
|
+
return taskId;
|
|
96
|
+
}
|
|
97
|
+
if (customTaskId) {
|
|
98
|
+
const resolvedTask = await this.taskService.getTaskByCustomId(customTaskId);
|
|
99
|
+
return resolvedTask.id;
|
|
100
|
+
}
|
|
101
|
+
if (taskName && listName) {
|
|
102
|
+
return await this.findTaskInList(taskName, listName);
|
|
103
|
+
}
|
|
104
|
+
throw new ClickUpServiceError('Invalid task identification. Provide either taskId, customTaskId, or both taskName and listName', ErrorCode.INVALID_PARAMETER);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Update multiple tasks
|
|
108
|
+
* @param tasks Array of tasks to update with their new data
|
|
109
|
+
* @param options Optional batch processing settings
|
|
110
|
+
* @returns Array of updated tasks
|
|
111
|
+
*/
|
|
112
|
+
async updateTasks(tasks, options) {
|
|
113
|
+
logger.info('Starting bulk update operation', { taskCount: tasks.length });
|
|
114
|
+
try {
|
|
115
|
+
return await processBatch(tasks, async (task) => {
|
|
116
|
+
const { taskId, taskName, listName, customTaskId, ...updateData } = task;
|
|
117
|
+
const resolvedTaskId = await this.resolveTaskId({ taskId, taskName, listName, customTaskId });
|
|
118
|
+
return await this.taskService.updateTask(resolvedTaskId, updateData);
|
|
119
|
+
}, options);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
logger.error('Bulk update operation failed', error);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Move multiple tasks to a different list
|
|
128
|
+
* @param tasks Array of tasks to move (each with taskId or taskName + listName)
|
|
129
|
+
* @param targetListId ID of the destination list or list name
|
|
130
|
+
* @param options Optional batch processing settings
|
|
131
|
+
* @returns Array of moved tasks
|
|
132
|
+
*/
|
|
133
|
+
async moveTasks(tasks, targetListId, options) {
|
|
134
|
+
logger.info('Starting bulk move operation', { taskCount: tasks.length, targetListId });
|
|
135
|
+
try {
|
|
136
|
+
// Determine if targetListId is actually an ID or a name
|
|
137
|
+
let resolvedTargetListId = targetListId;
|
|
138
|
+
// If the targetListId doesn't match the pattern of a list ID (usually just numbers),
|
|
139
|
+
// assume it's a list name and try to resolve it
|
|
140
|
+
if (!/^\d+$/.test(targetListId)) {
|
|
141
|
+
logger.info(`Target list appears to be a name: "${targetListId}", attempting to resolve`);
|
|
142
|
+
const listInfo = await findListIDByName(clickUpServices.workspace, targetListId);
|
|
143
|
+
if (!listInfo) {
|
|
144
|
+
throw new ClickUpServiceError(`Target list "${targetListId}" not found`, ErrorCode.NOT_FOUND);
|
|
145
|
+
}
|
|
146
|
+
resolvedTargetListId = listInfo.id;
|
|
147
|
+
logger.info(`Resolved target list to ID: ${resolvedTargetListId}`);
|
|
148
|
+
}
|
|
149
|
+
// Validate the destination list exists
|
|
150
|
+
await this.taskService.validateListExists(resolvedTargetListId);
|
|
151
|
+
return await processBatch(tasks, async (task) => {
|
|
152
|
+
const resolvedTaskId = await this.resolveTaskId(task);
|
|
153
|
+
return await this.taskService.moveTask(resolvedTaskId, resolvedTargetListId);
|
|
154
|
+
}, options);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
logger.error('Bulk move operation failed', error);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Delete multiple tasks
|
|
163
|
+
* @param tasks Array of tasks to delete (each with taskId or taskName + listName)
|
|
164
|
+
* @param options Batch processing options
|
|
165
|
+
* @returns Results containing successful and failed deletions
|
|
166
|
+
*/
|
|
167
|
+
async deleteTasks(tasks, options) {
|
|
168
|
+
logger.info('Starting bulk delete operation', { taskCount: tasks.length });
|
|
169
|
+
try {
|
|
170
|
+
return await processBatch(tasks, async (task) => {
|
|
171
|
+
const resolvedTaskId = await this.resolveTaskId(task);
|
|
172
|
+
await this.taskService.deleteTask(resolvedTaskId);
|
|
173
|
+
}, options);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
logger.error('Bulk delete operation failed', error);
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|