kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq } from "drizzle-orm";
2import { HTTPException } from "hono/http-exception";
3import db from "../../database";
4import {
5 activityTable,
6 integrationTable,
7 labelTable,
8 projectTable,
9 taskTable,
10} from "../../database/schema";
11import type { GitHubConfig } from "../../plugins/github/config";
12import {
13 createExternalLink,
14 findExternalLink,
15} from "../../plugins/github/services/link-manager";
16import { findTaskByNumber } from "../../plugins/github/services/task-service";
17import { extractTaskNumber } from "../../plugins/github/utils/branch-matcher";
18import {
19 extractIssuePriority,
20 extractIssueStatus,
21} from "../../plugins/github/utils/extract-priority";
22import { formatTaskDescriptionFromIssue } from "../../plugins/github/utils/format";
23import { getInstallationOctokit } from "../../plugins/github/utils/github-app";
24import getNextTaskNumber from "../../task/controllers/get-next-task-number";
25
26type ImportResult = {
27 imported: number;
28 updated: number;
29 skipped: number;
30 errors?: string[];
31};
32
33type GitHubIssue = {
34 number: number;
35 title: string;
36 body: string | null;
37 html_url: string;
38 state: string;
39 labels: Array<{ name?: string; color?: string } | string>;
40 user: { login: string; avatar_url: string } | null;
41 pull_request?: unknown;
42};
43
44type GitHubComment = {
45 id: number;
46 body: string;
47 html_url: string;
48 user: { login: string; avatar_url: string } | null;
49 created_at: string;
50};
51
52type GitHubPullRequest = {
53 number: number;
54 title: string;
55 body: string | null;
56 html_url: string;
57 state: string;
58 head: { ref: string };
59 user: { login: string; avatar_url: string } | null;
60};
61
62export async function importIssues(projectId: string): Promise<ImportResult> {
63 const errors: string[] = [];
64 let imported = 0;
65 let updated = 0;
66 let skipped = 0;
67
68 const project = await db.query.projectTable.findFirst({
69 where: eq(projectTable.id, projectId),
70 });
71
72 if (!project) {
73 throw new HTTPException(404, { message: "Project not found" });
74 }
75
76 const integration = await db.query.integrationTable.findFirst({
77 where: and(
78 eq(integrationTable.projectId, projectId),
79 eq(integrationTable.type, "github"),
80 ),
81 });
82
83 if (!integration) {
84 throw new HTTPException(404, { message: "GitHub integration not found" });
85 }
86
87 if (!integration.isActive) {
88 throw new HTTPException(400, {
89 message: "GitHub integration is not active",
90 });
91 }
92
93 const config = JSON.parse(integration.config) as GitHubConfig;
94
95 if (!config.installationId) {
96 throw new HTTPException(400, {
97 message: "GitHub installation ID not configured",
98 });
99 }
100
101 const octokit = await getInstallationOctokit(config.installationId);
102
103 const allIssues: GitHubIssue[] = [];
104 let page = 1;
105 const perPage = 100;
106
107 while (true) {
108 const { data: issues } = await octokit.rest.issues.listForRepo({
109 owner: config.repositoryOwner,
110 repo: config.repositoryName,
111 state: "open",
112 per_page: perPage,
113 page,
114 });
115
116 if (issues.length === 0) break;
117
118 const issuesOnly = issues.filter(
119 (issue) => !issue.pull_request,
120 ) as GitHubIssue[];
121 allIssues.push(...issuesOnly);
122
123 if (issues.length < perPage) break;
124 page++;
125 }
126
127 for (const issue of allIssues) {
128 try {
129 const result = await importSingleIssue(
130 issue,
131 integration.id,
132 projectId,
133 project.workspaceId,
134 config,
135 octokit,
136 );
137
138 if (result === "imported") {
139 imported++;
140 } else if (result === "updated") {
141 updated++;
142 } else {
143 skipped++;
144 }
145 } catch (error) {
146 const errorMessage =
147 error instanceof Error ? error.message : String(error);
148 errors.push(`Issue #${issue.number}: ${errorMessage}`);
149 }
150 }
151
152 const allPRs: GitHubPullRequest[] = [];
153 page = 1;
154
155 while (true) {
156 const { data: pulls } = await octokit.rest.pulls.list({
157 owner: config.repositoryOwner,
158 repo: config.repositoryName,
159 state: "open",
160 per_page: perPage,
161 page,
162 });
163
164 if (pulls.length === 0) break;
165
166 allPRs.push(...(pulls as GitHubPullRequest[]));
167
168 if (pulls.length < perPage) break;
169 page++;
170 }
171
172 for (const pr of allPRs) {
173 try {
174 await linkPullRequestToTask(
175 pr,
176 integration.id,
177 projectId,
178 project.slug,
179 config,
180 );
181 } catch (error) {
182 const errorMessage =
183 error instanceof Error ? error.message : String(error);
184 errors.push(`PR #${pr.number}: ${errorMessage}`);
185 }
186 }
187
188 return {
189 imported,
190 updated,
191 skipped,
192 ...(errors.length > 0 ? { errors } : {}),
193 };
194}
195
196async function importSingleIssue(
197 issue: GitHubIssue,
198 integrationId: string,
199 projectId: string,
200 workspaceId: string,
201 config: GitHubConfig,
202 octokit: Awaited<ReturnType<typeof getInstallationOctokit>>,
203): Promise<"imported" | "updated" | "skipped"> {
204 const existingLink = await findExternalLink(
205 integrationId,
206 "issue",
207 issue.number.toString(),
208 );
209
210 const priority = extractIssuePriority(issue.labels);
211 const status = extractIssueStatus(issue.labels);
212
213 if (existingLink) {
214 const updateData: Record<string, unknown> = {
215 title: issue.title,
216 description: formatTaskDescriptionFromIssue(issue.body),
217 };
218
219 if (priority) updateData.priority = priority;
220 if (status) updateData.status = status;
221
222 await db
223 .update(taskTable)
224 .set(updateData)
225 .where(eq(taskTable.id, existingLink.taskId));
226
227 await importLabelsForTask(issue.labels, existingLink.taskId, workspaceId);
228
229 await importCommentsForTask(
230 issue.number,
231 existingLink.taskId,
232 config,
233 octokit,
234 );
235
236 return "updated";
237 }
238
239 const nextTaskNumber = await getNextTaskNumber(projectId);
240
241 const taskValues: typeof taskTable.$inferInsert = {
242 projectId,
243 userId: null,
244 title: issue.title,
245 description: formatTaskDescriptionFromIssue(issue.body),
246 status: status || "to-do",
247 priority: priority || null,
248 number: nextTaskNumber + 1,
249 };
250
251 const [createdTask] = await db
252 .insert(taskTable)
253 .values(taskValues)
254 .returning();
255
256 if (!createdTask) {
257 throw new Error("Failed to create task");
258 }
259
260 await createExternalLink({
261 taskId: createdTask.id,
262 integrationId,
263 resourceType: "issue",
264 externalId: issue.number.toString(),
265 url: issue.html_url,
266 title: issue.title,
267 metadata: {
268 state: issue.state,
269 createdFrom: "github-import",
270 author: issue.user?.login,
271 },
272 });
273
274 await importLabelsForTask(issue.labels, createdTask.id, workspaceId);
275
276 await importCommentsForTask(issue.number, createdTask.id, config, octokit);
277
278 return "imported";
279}
280
281async function importLabelsForTask(
282 issueLabels: GitHubIssue["labels"],
283 taskId: string,
284 workspaceId: string,
285): Promise<void> {
286 const nonSystemLabels = issueLabels
287 .map((label) => {
288 if (typeof label === "string") {
289 return { name: label, color: "#6B7280" };
290 }
291 return {
292 name: label.name,
293 color: label.color ? `#${label.color}` : "#6B7280",
294 };
295 })
296 .filter(
297 (label) =>
298 label.name &&
299 !label.name.startsWith("priority:") &&
300 !label.name.startsWith("status:"),
301 ) as Array<{ name: string; color: string }>;
302
303 for (const labelData of nonSystemLabels) {
304 const existingLabelOnTask = await db.query.labelTable.findFirst({
305 where: and(
306 eq(labelTable.taskId, taskId),
307 eq(labelTable.name, labelData.name),
308 ),
309 });
310
311 if (existingLabelOnTask) {
312 continue;
313 }
314
315 const existingWorkspaceLabel = await db.query.labelTable.findFirst({
316 where: and(
317 eq(labelTable.workspaceId, workspaceId),
318 eq(labelTable.name, labelData.name),
319 ),
320 });
321
322 const colorToUse = existingWorkspaceLabel?.color || labelData.color;
323
324 await db.insert(labelTable).values({
325 name: labelData.name,
326 color: colorToUse,
327 taskId,
328 workspaceId,
329 });
330 }
331}
332
333async function importCommentsForTask(
334 issueNumber: number,
335 taskId: string,
336 config: GitHubConfig,
337 octokit: Awaited<ReturnType<typeof getInstallationOctokit>>,
338): Promise<void> {
339 const allComments: GitHubComment[] = [];
340 let page = 1;
341 const perPage = 100;
342
343 while (true) {
344 const { data: comments } = await octokit.rest.issues.listComments({
345 owner: config.repositoryOwner,
346 repo: config.repositoryName,
347 issue_number: issueNumber,
348 per_page: perPage,
349 page,
350 });
351
352 if (comments.length === 0) break;
353
354 allComments.push(...(comments as GitHubComment[]));
355
356 if (comments.length < perPage) break;
357 page++;
358 }
359
360 const existingActivities = await db.query.activityTable.findMany({
361 where: and(
362 eq(activityTable.taskId, taskId),
363 eq(activityTable.externalSource, "github"),
364 ),
365 });
366
367 const existingExternalUrls = new Set(
368 existingActivities.filter((a) => a.externalUrl).map((a) => a.externalUrl),
369 );
370
371 for (const comment of allComments) {
372 const username = comment.user?.login ?? "";
373 if (username.endsWith("[bot]")) {
374 continue;
375 }
376
377 if (existingExternalUrls.has(comment.html_url)) {
378 continue;
379 }
380
381 await db.insert(activityTable).values({
382 taskId,
383 type: "comment",
384 content: comment.body,
385 externalUserName: comment.user?.login ?? "Unknown",
386 externalUserAvatar: comment.user?.avatar_url ?? null,
387 externalSource: "github",
388 externalUrl: comment.html_url,
389 });
390 }
391}
392
393async function linkPullRequestToTask(
394 pr: GitHubPullRequest,
395 integrationId: string,
396 projectId: string,
397 projectSlug: string,
398 config: GitHubConfig,
399): Promise<void> {
400 const taskNumber = extractTaskNumber(
401 pr.head.ref,
402 pr.title,
403 pr.body ?? undefined,
404 config,
405 projectSlug,
406 );
407
408 if (!taskNumber) {
409 return;
410 }
411
412 const task = await findTaskByNumber(projectId, taskNumber);
413
414 if (!task) {
415 return;
416 }
417
418 const existingLink = await findExternalLink(
419 integrationId,
420 "pull_request",
421 pr.number.toString(),
422 );
423
424 if (existingLink) {
425 return;
426 }
427
428 await createExternalLink({
429 taskId: task.id,
430 integrationId,
431 resourceType: "pull_request",
432 externalId: pr.number.toString(),
433 url: pr.html_url,
434 title: pr.title,
435 metadata: {
436 state: pr.state,
437 branch: pr.head.ref,
438 author: pr.user?.login,
439 },
440 });
441}