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
325 .insert(labelTable)
326 .values({
327 name: labelData.name,
328 color: colorToUse,
329 taskId,
330 workspaceId,
331 })
332 .onConflictDoNothing({
333 target: [labelTable.taskId, labelTable.name],
334 });
335 }
336}
337
338async function importCommentsForTask(
339 issueNumber: number,
340 taskId: string,
341 config: GitHubConfig,
342 octokit: Awaited<ReturnType<typeof getInstallationOctokit>>,
343): Promise<void> {
344 const allComments: GitHubComment[] = [];
345 let page = 1;
346 const perPage = 100;
347
348 while (true) {
349 const { data: comments } = await octokit.rest.issues.listComments({
350 owner: config.repositoryOwner,
351 repo: config.repositoryName,
352 issue_number: issueNumber,
353 per_page: perPage,
354 page,
355 });
356
357 if (comments.length === 0) break;
358
359 allComments.push(...(comments as GitHubComment[]));
360
361 if (comments.length < perPage) break;
362 page++;
363 }
364
365 const existingActivities = await db.query.activityTable.findMany({
366 where: and(
367 eq(activityTable.taskId, taskId),
368 eq(activityTable.externalSource, "github"),
369 ),
370 });
371
372 const existingExternalUrls = new Set(
373 existingActivities.filter((a) => a.externalUrl).map((a) => a.externalUrl),
374 );
375
376 for (const comment of allComments) {
377 const username = comment.user?.login ?? "";
378 if (username.endsWith("[bot]")) {
379 continue;
380 }
381
382 if (existingExternalUrls.has(comment.html_url)) {
383 continue;
384 }
385
386 await db
387 .insert(activityTable)
388 .values({
389 taskId,
390 type: "comment",
391 content: comment.body,
392 externalUserName: comment.user?.login ?? "Unknown",
393 externalUserAvatar: comment.user?.avatar_url ?? null,
394 externalSource: "github",
395 externalUrl: comment.html_url,
396 })
397 .onConflictDoNothing({
398 target: [
399 activityTable.taskId,
400 activityTable.externalSource,
401 activityTable.externalUrl,
402 ],
403 });
404 }
405}
406
407async function linkPullRequestToTask(
408 pr: GitHubPullRequest,
409 integrationId: string,
410 projectId: string,
411 projectSlug: string,
412 config: GitHubConfig,
413): Promise<void> {
414 const taskNumber = extractTaskNumber(
415 pr.head.ref,
416 pr.title,
417 pr.body ?? undefined,
418 config,
419 projectSlug,
420 );
421
422 if (!taskNumber) {
423 return;
424 }
425
426 const task = await findTaskByNumber(projectId, taskNumber);
427
428 if (!task) {
429 return;
430 }
431
432 const existingLink = await findExternalLink(
433 integrationId,
434 "pull_request",
435 pr.number.toString(),
436 );
437
438 if (existingLink) {
439 return;
440 }
441
442 await createExternalLink({
443 taskId: task.id,
444 integrationId,
445 resourceType: "pull_request",
446 externalId: pr.number.toString(),
447 url: pr.html_url,
448 title: pr.title,
449 metadata: {
450 state: pr.state,
451 branch: pr.head.ref,
452 author: pr.user?.login,
453 },
454 });
455}