/** * ClickUp API v2 client with rate limiting. * * Rate limit: 100 requests per minute per token. * Uses a sliding-window token bucket with automatic backoff. */ const CLICKUP_API = 'https://api.clickup.com/api/v2'; const RATE_LIMIT = 100; const RATE_WINDOW_MS = 60_000; // ── Rate limiter ── interface RateBucket { timestamps: number[]; } const buckets = new Map(); async function waitForSlot(token: string): Promise { let bucket = buckets.get(token); if (!bucket) { bucket = { timestamps: [] }; buckets.set(token, bucket); } const now = Date.now(); bucket.timestamps = bucket.timestamps.filter(t => now - t < RATE_WINDOW_MS); if (bucket.timestamps.length >= RATE_LIMIT) { const oldest = bucket.timestamps[0]; const waitMs = RATE_WINDOW_MS - (now - oldest) + 50; await new Promise(r => setTimeout(r, waitMs)); return waitForSlot(token); // retry } bucket.timestamps.push(Date.now()); } // ── HTTP helpers ── export class ClickUpApiError extends Error { constructor( public statusCode: number, public body: string, public endpoint: string, ) { super(`ClickUp API ${statusCode} on ${endpoint}: ${body.slice(0, 200)}`); this.name = 'ClickUpApiError'; } } async function request( token: string, method: string, path: string, body?: Record, ): Promise { await waitForSlot(token); const url = `${CLICKUP_API}${path}`; const headers: Record = { 'Authorization': token, 'Content-Type': 'application/json', }; const res = await fetch(url, { method, headers, ...(body ? { body: JSON.stringify(body) } : {}), }); if (!res.ok) { const text = await res.text(); throw new ClickUpApiError(res.status, text, `${method} ${path}`); } const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return res.json(); } return res.text(); } // ── Client class ── export class ClickUpClient { constructor(private token: string) {} // ── Teams (Workspaces) ── async getTeams(): Promise { const data = await request(this.token, 'GET', '/team'); return data.teams || []; } // ── Spaces ── async getSpaces(teamId: string): Promise { const data = await request(this.token, 'GET', `/team/${teamId}/space?archived=false`); return data.spaces || []; } // ── Folders & Lists ── async getFolders(spaceId: string): Promise { const data = await request(this.token, 'GET', `/space/${spaceId}/folder?archived=false`); return data.folders || []; } async getFolderlessLists(spaceId: string): Promise { const data = await request(this.token, 'GET', `/space/${spaceId}/list?archived=false`); return data.lists || []; } async getListsInFolder(folderId: string): Promise { const data = await request(this.token, 'GET', `/folder/${folderId}/list?archived=false`); return data.lists || []; } // ── Tasks ── async getTasks(listId: string, page = 0): Promise { const data = await request( this.token, 'GET', `/list/${listId}/task?page=${page}&include_closed=true&subtasks=true`, ); return data.tasks || []; } async getTask(taskId: string): Promise { return request(this.token, 'GET', `/task/${taskId}`); } async createTask(listId: string, body: Record): Promise { return request(this.token, 'POST', `/list/${listId}/task`, body); } async updateTask(taskId: string, body: Record): Promise { return request(this.token, 'PUT', `/task/${taskId}`, body); } // ── Webhooks ── async createWebhook(teamId: string, endpoint: string, events: string[], secret?: string): Promise { const body: Record = { endpoint, events }; if (secret) body.secret = secret; return request(this.token, 'POST', `/team/${teamId}/webhook`, body); } async deleteWebhook(webhookId: string): Promise { await request(this.token, 'DELETE', `/webhook/${webhookId}`); } async getWebhooks(teamId: string): Promise { const data = await request(this.token, 'GET', `/team/${teamId}/webhook`); return data.webhooks || []; } // ── List details (for status mapping) ── async getList(listId: string): Promise { return request(this.token, 'GET', `/list/${listId}`); } }