Skip to content

Commit 94387c6

Browse files
committed
feat(importer): add comments to task
1 parent c1ae150 commit 94387c6

3 files changed

Lines changed: 101 additions & 10 deletions

File tree

packages/import/src/cli.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ inquirer.registerPrompt("filePath", require("inquirer-file-path"));
2929
name: "service",
3030
message: "Which service would you like to import from?",
3131
choices: [
32+
{
33+
name: "ClickUp (API)",
34+
value: "clickup",
35+
},
3236
{
3337
name: "GitHub",
3438
value: "github",
@@ -61,10 +65,6 @@ inquirer.registerPrompt("filePath", require("inquirer-file-path"));
6165
name: "Linear (CSV export)",
6266
value: "linearCsv",
6367
},
64-
{
65-
name: "ClickUp (API)",
66-
value: "clickup",
67-
},
6868
],
6969
},
7070
]);

packages/import/src/importers/clickupCsv/ClickupApiImporter.test.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,25 @@ const testSingleTaskImport = async () => {
3636
};
3737

3838
const mockFetch = async (url: string) => {
39-
if (url.includes("/task/task-1")) {
39+
if (typeof url === "string" && url.includes("/task/task-1/comment")) {
40+
return makeResponse(200, {
41+
comments: [
42+
{
43+
id: "c1",
44+
comment_text: "First comment",
45+
date: `1000`,
46+
user: { email: "dev@example.com" },
47+
},
48+
{
49+
id: "c2",
50+
comment_text: "Second comment",
51+
date: `2000`,
52+
user: { email: "qa@example.com" },
53+
},
54+
],
55+
});
56+
}
57+
if (typeof url === "string" && url.includes("/task/task-1")) {
4058
return makeResponse(200, mockTask);
4159
}
4260
throw new Error(`Unexpected URL: ${url}`);
@@ -53,6 +71,12 @@ const testSingleTaskImport = async () => {
5371
assert.ok(issue.assigneeId?.includes("dev"), "assignee is mapped");
5472
assert.ok(issue.labels?.includes("bug"), "tag label included");
5573
assert.ok(issue.labels?.includes("BoardLabel"), "board label added");
74+
const description = issue.description ?? "";
75+
assert.ok(description.includes("ClickUp comments"), "comments header present");
76+
assert.ok(
77+
description.indexOf("First comment") < description.indexOf("Second comment"),
78+
"comments sorted chronologically"
79+
);
5680
};
5781

5882
const testListImportWithLimit = async () => {
@@ -62,9 +86,12 @@ const testListImportWithLimit = async () => {
6286
];
6387

6488
const mockFetch = async (url: string) => {
65-
if (url.includes("/list/123/task")) {
89+
if (typeof url === "string" && url.includes("/list/123/task")) {
6690
return makeResponse(200, { tasks });
6791
}
92+
if (typeof url === "string" && url.includes("/comment")) {
93+
return makeResponse(200, { comments: [] });
94+
}
6895
throw new Error(`Unexpected URL: ${url}`);
6996
};
7097

packages/import/src/importers/clickupCsv/ClickupApiImporter.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fetch from "node-fetch";
22
import { Importer, ImportResult, IssuePriority } from "../../types";
3+
import type { Comment as LinearComment } from "../../types";
34

45
type ClickupPriority = "urgent" | "high" | "normal" | "low" | null;
56

@@ -40,6 +41,21 @@ interface ClickupTask {
4041
due_date?: string;
4142
}
4243

44+
interface ClickupComment {
45+
id: string;
46+
comment_text?: string;
47+
date?: string;
48+
user?: {
49+
id?: number;
50+
username?: string;
51+
email?: string;
52+
};
53+
}
54+
55+
interface ClickupCommentsResponse {
56+
comments: ClickupComment[];
57+
}
58+
4359
interface ClickupTaskResponse {
4460
tasks: ClickupTask[];
4561
}
@@ -52,7 +68,7 @@ export interface ClickupImporterOptions {
5268
statusMapping?: Record<string, string>;
5369
maxIssues?: number;
5470
singleTaskId?: string;
55-
fetchImpl?: typeof fetch;
71+
fetchImpl?: (url: string, init?: any) => Promise<any>;
5672
}
5773

5874
/**
@@ -67,7 +83,7 @@ export class ClickupApiImporter implements Importer {
6783
this.statusMapping = options.statusMapping ?? {};
6884
this.maxIssues = options.maxIssues ?? 1;
6985
this.singleTaskId = options.singleTaskId;
70-
this.fetchImpl = options.fetchImpl ?? fetch;
86+
this.fetchImpl = (options.fetchImpl ?? fetch) as (url: string, init?: any) => Promise<any>;
7187
}
7288

7389
public get name(): string {
@@ -90,12 +106,17 @@ export class ClickupApiImporter implements Importer {
90106
for (const task of tasks) {
91107
const baseDescription = task.description || task.text_content || undefined;
92108
const originalUrl = task.url;
109+
const comments = await this.fetchComments(task.id);
110+
const commentsBlock = this.formatComments(comments);
111+
93112
const description =
94113
originalUrl && baseDescription
95114
? `${baseDescription}\n\n[View original task in ClickUp](${originalUrl})`
96115
: originalUrl
97116
? `[View original task in ClickUp](${originalUrl})`
98117
: baseDescription;
118+
const fullDescription =
119+
commentsBlock && description ? `${description}\n\n---\n\n${commentsBlock}` : commentsBlock || description;
99120
const statusName = this.mapStatus(task.status?.status);
100121
const priority = this.mapPriority(task.priority?.priority);
101122

@@ -133,7 +154,7 @@ export class ClickupApiImporter implements Importer {
133154

134155
importData.issues.push({
135156
title: task.name,
136-
description,
157+
description: fullDescription,
137158
status: statusName,
138159
priority,
139160
url: originalUrl,
@@ -224,12 +245,55 @@ export class ClickupApiImporter implements Importer {
224245
return Number.isNaN(num) ? undefined : new Date(num);
225246
}
226247

248+
private async fetchComments(taskId: string): Promise<LinearComment[]> {
249+
try {
250+
const response = await this.fetchImpl(`${this.apiBaseUrl}/task/${taskId}/comment`, {
251+
headers: {
252+
Authorization: this.apiToken,
253+
"Content-Type": "application/json",
254+
},
255+
});
256+
257+
if (!response.ok) {
258+
return [];
259+
}
260+
261+
const data = (await response.json()) as ClickupCommentsResponse;
262+
return (data.comments || []).map(comment => {
263+
const userKey = (comment.user?.email || comment.user?.username || "").toLowerCase();
264+
return {
265+
body: comment.comment_text,
266+
userId: userKey || "unknown",
267+
createdAt: this.toDate(comment.date),
268+
};
269+
});
270+
} catch {
271+
return [];
272+
}
273+
}
274+
275+
private formatComments(comments: LinearComment[]): string | undefined {
276+
if (!comments.length) {
277+
return undefined;
278+
}
279+
const sorted = [...comments].sort((a, b) => {
280+
const aTime = a.createdAt ? a.createdAt.getTime() : 0;
281+
const bTime = b.createdAt ? b.createdAt.getTime() : 0;
282+
return aTime - bTime;
283+
});
284+
const blocks = sorted.map(comment => {
285+
const date = comment.createdAt ? comment.createdAt.toISOString().split("T")[0] : "";
286+
return `**${comment.userId || "Unknown"}** ${date}\n\n${comment.body ?? ""}`;
287+
});
288+
return `### ClickUp comments\n\n${blocks.join("\n\n---\n\n")}`;
289+
}
290+
227291
private listId: string;
228292
private apiToken: string;
229293
private apiBaseUrl: string;
230294
private labelForBoard?: string;
231295
private statusMapping: Record<string, string>;
232296
private maxIssues: number;
233297
private singleTaskId?: string;
234-
private fetchImpl: typeof fetch;
298+
private fetchImpl: (url: string, init?: any) => Promise<any>;
235299
}

0 commit comments

Comments
 (0)