166 lines
4.3 KiB
TypeScript
166 lines
4.3 KiB
TypeScript
/**
|
|
* 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<string, RateBucket>();
|
|
|
|
async function waitForSlot(token: string): Promise<void> {
|
|
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<string, any>,
|
|
): Promise<any> {
|
|
await waitForSlot(token);
|
|
|
|
const url = `${CLICKUP_API}${path}`;
|
|
const headers: Record<string, string> = {
|
|
'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<any[]> {
|
|
const data = await request(this.token, 'GET', '/team');
|
|
return data.teams || [];
|
|
}
|
|
|
|
// ── Spaces ──
|
|
|
|
async getSpaces(teamId: string): Promise<any[]> {
|
|
const data = await request(this.token, 'GET', `/team/${teamId}/space?archived=false`);
|
|
return data.spaces || [];
|
|
}
|
|
|
|
// ── Folders & Lists ──
|
|
|
|
async getFolders(spaceId: string): Promise<any[]> {
|
|
const data = await request(this.token, 'GET', `/space/${spaceId}/folder?archived=false`);
|
|
return data.folders || [];
|
|
}
|
|
|
|
async getFolderlessLists(spaceId: string): Promise<any[]> {
|
|
const data = await request(this.token, 'GET', `/space/${spaceId}/list?archived=false`);
|
|
return data.lists || [];
|
|
}
|
|
|
|
async getListsInFolder(folderId: string): Promise<any[]> {
|
|
const data = await request(this.token, 'GET', `/folder/${folderId}/list?archived=false`);
|
|
return data.lists || [];
|
|
}
|
|
|
|
// ── Tasks ──
|
|
|
|
async getTasks(listId: string, page = 0): Promise<any[]> {
|
|
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<any> {
|
|
return request(this.token, 'GET', `/task/${taskId}`);
|
|
}
|
|
|
|
async createTask(listId: string, body: Record<string, any>): Promise<any> {
|
|
return request(this.token, 'POST', `/list/${listId}/task`, body);
|
|
}
|
|
|
|
async updateTask(taskId: string, body: Record<string, any>): Promise<any> {
|
|
return request(this.token, 'PUT', `/task/${taskId}`, body);
|
|
}
|
|
|
|
// ── Webhooks ──
|
|
|
|
async createWebhook(teamId: string, endpoint: string, events: string[], secret?: string): Promise<any> {
|
|
const body: Record<string, any> = { endpoint, events };
|
|
if (secret) body.secret = secret;
|
|
return request(this.token, 'POST', `/team/${teamId}/webhook`, body);
|
|
}
|
|
|
|
async deleteWebhook(webhookId: string): Promise<void> {
|
|
await request(this.token, 'DELETE', `/webhook/${webhookId}`);
|
|
}
|
|
|
|
async getWebhooks(teamId: string): Promise<any[]> {
|
|
const data = await request(this.token, 'GET', `/team/${teamId}/webhook`);
|
|
return data.webhooks || [];
|
|
}
|
|
|
|
// ── List details (for status mapping) ──
|
|
|
|
async getList(listId: string): Promise<any> {
|
|
return request(this.token, 'GET', `/list/${listId}`);
|
|
}
|
|
}
|