kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq, inArray, max, notInArray } 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 { publishEvent } from "../../events";
12import type { GiteaConfig } from "../../plugins/gitea/config";
13import { extractTaskNumberGitea } from "../../plugins/gitea/utils/branch-matcher";
14import {
15 createGiteaClient,
16 type GiteaIssue,
17 type GiteaLabel,
18 type GiteaPullRequest,
19} from "../../plugins/gitea/utils/gitea-api";
20import {
21 createExternalLink,
22 findExternalLink,
23} from "../../plugins/github/services/link-manager";
24import { findTaskByNumber } from "../../plugins/github/services/task-service";
25import {
26 extractIssuePriority,
27 extractIssueStatus,
28} from "../../plugins/github/utils/extract-priority";
29import { formatTaskDescriptionFromIssue } from "../../plugins/github/utils/format";
30
31type ImportResult = {
32 imported: number;
33 updated: number;
34 skipped: number;
35 errors?: string[];
36};
37
38type LabelLike = { name?: string };
39
40function toPriorityLabels(labels: GiteaLabel[]): LabelLike[] {
41 return labels.map((label) => ({ name: label.name }));
42}
43
44export async function importGiteaIssues(
45 projectId: string,
46): Promise<ImportResult> {
47 const errors: string[] = [];
48 let imported = 0;
49 let updated = 0;
50 let skipped = 0;
51
52 const project = await db.query.projectTable.findFirst({
53 where: eq(projectTable.id, projectId),
54 });
55
56 if (!project) {
57 throw new HTTPException(404, { message: "Project not found" });
58 }
59
60 const integration = await db.query.integrationTable.findFirst({
61 where: and(
62 eq(integrationTable.projectId, projectId),
63 eq(integrationTable.type, "gitea"),
64 ),
65 });
66
67 if (!integration) {
68 throw new HTTPException(404, { message: "Gitea integration not found" });
69 }
70
71 if (!integration.isActive) {
72 throw new HTTPException(400, {
73 message: "Gitea integration is not active",
74 });
75 }
76
77 let config: GiteaConfig;
78 try {
79 config = JSON.parse(integration.config) as GiteaConfig;
80 } catch (error) {
81 const message = error instanceof Error ? error.message : String(error);
82 console.warn("Invalid Gitea integration config JSON", {
83 integrationId: integration.id,
84 error,
85 });
86 throw new HTTPException(400, {
87 message: `Invalid Gitea integration config: ${message}`,
88 });
89 }
90
91 if (!config.accessToken || !config.baseUrl) {
92 throw new HTTPException(400, {
93 message: "Gitea access token or base URL not configured",
94 });
95 }
96
97 const client = createGiteaClient(config);
98
99 const allIssues: GiteaIssue[] = [];
100 let page = 1;
101
102 while (true) {
103 const issues = await client.listIssues(
104 config.repositoryOwner,
105 config.repositoryName,
106 page,
107 "open",
108 );
109
110 if (issues.length === 0) break;
111
112 const issuesOnly = issues.filter((issue) => !issue.pull_request);
113 allIssues.push(...issuesOnly);
114
115 if (issues.length < 100) break;
116 page++;
117 }
118
119 for (const issue of allIssues) {
120 try {
121 const result = await importSingleIssue(
122 issue,
123 integration.id,
124 projectId,
125 project.workspaceId,
126 config,
127 client,
128 );
129
130 if (result === "imported") {
131 imported++;
132 } else if (result === "updated") {
133 updated++;
134 } else {
135 skipped++;
136 }
137 } catch (error) {
138 const errorMessage =
139 error instanceof Error ? error.message : String(error);
140 errors.push(`Issue #${issue.number}: ${errorMessage}`);
141 }
142 }
143
144 const allPRs: GiteaPullRequest[] = [];
145 page = 1;
146
147 while (true) {
148 const pulls = await client.listPulls(
149 config.repositoryOwner,
150 config.repositoryName,
151 page,
152 );
153
154 if (pulls.length === 0) break;
155
156 allPRs.push(...pulls);
157
158 if (pulls.length < 100) break;
159 page++;
160 }
161
162 for (const pr of allPRs) {
163 try {
164 if (!pr.head?.ref) {
165 continue;
166 }
167 await linkPullRequestToTask(
168 {
169 ...pr,
170 head: { ref: pr.head.ref },
171 },
172 integration.id,
173 projectId,
174 project.slug,
175 config,
176 );
177 } catch (error) {
178 const errorMessage =
179 error instanceof Error ? error.message : String(error);
180 errors.push(`PR #${pr.number}: ${errorMessage}`);
181 }
182 }
183
184 return {
185 imported,
186 updated,
187 skipped,
188 ...(errors.length > 0 ? { errors } : {}),
189 };
190}
191
192async function importSingleIssue(
193 issue: GiteaIssue,
194 integrationId: string,
195 projectId: string,
196 workspaceId: string,
197 config: GiteaConfig,
198 client: ReturnType<typeof createGiteaClient>,
199): Promise<"imported" | "updated" | "skipped"> {
200 const existingLink = await findExternalLink(
201 integrationId,
202 "issue",
203 issue.number.toString(),
204 );
205
206 const labels = issue.labels ?? [];
207 const adaptedLabels = toPriorityLabels(labels);
208 const priority = extractIssuePriority(adaptedLabels);
209 const status = extractIssueStatus(adaptedLabels);
210
211 if (existingLink) {
212 const updateData: Record<string, unknown> = {
213 title: issue.title,
214 description: formatTaskDescriptionFromIssue(issue.body),
215 };
216
217 if (priority) updateData.priority = priority;
218 if (status) updateData.status = status;
219
220 await db
221 .update(taskTable)
222 .set(updateData)
223 .where(eq(taskTable.id, existingLink.taskId));
224
225 await importLabelsForTask(labels, existingLink.taskId, workspaceId);
226
227 await importCommentsForTask(
228 issue.number,
229 existingLink.taskId,
230 config,
231 client,
232 );
233
234 return "updated";
235 }
236
237 const createdTask = await db.transaction(async (tx) => {
238 const [lockedProject] = await tx
239 .select()
240 .from(projectTable)
241 .where(eq(projectTable.id, projectId))
242 .for("update");
243
244 if (!lockedProject) {
245 throw new Error("Project not found");
246 }
247
248 const [result] = await tx
249 .select({ maxNumber: max(taskTable.number) })
250 .from(taskTable)
251 .where(eq(taskTable.projectId, projectId));
252
253 const nextNumber = (result?.maxNumber ?? 0) + 1;
254
255 const taskValues: typeof taskTable.$inferInsert = {
256 projectId,
257 userId: null,
258 title: issue.title,
259 description: formatTaskDescriptionFromIssue(issue.body),
260 status: status || "to-do",
261 priority: priority || null,
262 number: nextNumber,
263 };
264
265 const [created] = await tx.insert(taskTable).values(taskValues).returning();
266
267 if (!created) {
268 throw new Error("Failed to create task");
269 }
270
271 return created;
272 });
273
274 await createExternalLink({
275 taskId: createdTask.id,
276 integrationId,
277 resourceType: "issue",
278 externalId: issue.number.toString(),
279 url: issue.html_url,
280 title: issue.title,
281 metadata: {
282 state: issue.state,
283 createdFrom: "gitea-import",
284 author: issue.user?.login ?? issue.user?.username,
285 },
286 });
287
288 await importLabelsForTask(labels, createdTask.id, workspaceId);
289
290 await importCommentsForTask(issue.number, createdTask.id, config, client);
291
292 await publishEvent("task.created", {
293 ...createdTask,
294 taskId: createdTask.id,
295 userId: createdTask.userId ?? "",
296 type: "task",
297 content: null,
298 source: "gitea-import",
299 integrationId,
300 externalId: issue.number.toString(),
301 });
302
303 return "imported";
304}
305
306async function importLabelsForTask(
307 issueLabels: GiteaIssue["labels"],
308 taskId: string,
309 workspaceId: string,
310): Promise<void> {
311 const nonSystemLabels = (issueLabels ?? [])
312 .map((label) => {
313 if (typeof label === "string") {
314 return { name: label, color: "#6B7280" };
315 }
316 return {
317 name: label.name,
318 color: label.color
319 ? `#${String(label.color).replace(/^#/, "")}`
320 : "#6B7280",
321 };
322 })
323 .filter(
324 (label) =>
325 label.name &&
326 !label.name.startsWith("priority:") &&
327 !label.name.startsWith("status:"),
328 ) as Array<{ name: string; color: string }>;
329
330 const expectedNames = nonSystemLabels.map((label) => label.name);
331
332 if (expectedNames.length > 0) {
333 await db
334 .delete(labelTable)
335 .where(
336 and(
337 eq(labelTable.taskId, taskId),
338 notInArray(labelTable.name, expectedNames),
339 ),
340 );
341 } else {
342 await db.delete(labelTable).where(eq(labelTable.taskId, taskId));
343 }
344
345 const existingLabelsOnTask = await db.query.labelTable.findMany({
346 where:
347 expectedNames.length > 0
348 ? and(
349 eq(labelTable.taskId, taskId),
350 inArray(labelTable.name, expectedNames),
351 )
352 : eq(labelTable.taskId, taskId),
353 });
354
355 for (const labelData of nonSystemLabels) {
356 const existingLabelOnTask = existingLabelsOnTask.find(
357 (label) => label.name === labelData.name,
358 );
359
360 if (existingLabelOnTask) {
361 continue;
362 }
363
364 const existingWorkspaceLabel = await db.query.labelTable.findFirst({
365 where: and(
366 eq(labelTable.workspaceId, workspaceId),
367 eq(labelTable.name, labelData.name),
368 ),
369 });
370
371 const colorToUse = existingWorkspaceLabel?.color || labelData.color;
372
373 await db
374 .insert(labelTable)
375 .values({
376 name: labelData.name,
377 color: colorToUse,
378 taskId,
379 workspaceId,
380 })
381 .onConflictDoNothing({
382 target: [labelTable.taskId, labelTable.name],
383 });
384 }
385}
386
387async function importCommentsForTask(
388 issueNumber: number,
389 taskId: string,
390 config: GiteaConfig,
391 client: ReturnType<typeof createGiteaClient>,
392): Promise<void> {
393 const allComments: Array<{
394 id: number;
395 body: string;
396 html_url: string;
397 user?: { login?: string; username?: string; avatar_url?: string } | null;
398 }> = [];
399 let page = 1;
400
401 while (true) {
402 const comments = await client.listIssueComments(
403 config.repositoryOwner,
404 config.repositoryName,
405 issueNumber,
406 page,
407 100,
408 );
409
410 if (comments.length === 0) break;
411
412 allComments.push(...comments);
413
414 if (comments.length < 100) break;
415 page++;
416 }
417
418 for (const comment of allComments) {
419 const username = comment.user?.login ?? comment.user?.username ?? "";
420 if (username.endsWith("[bot]")) {
421 continue;
422 }
423
424 await db
425 .insert(activityTable)
426 .values({
427 taskId,
428 type: "comment",
429 content: comment.body,
430 externalUserName: username || "Unknown",
431 externalUserAvatar: comment.user?.avatar_url ?? null,
432 externalSource: "gitea",
433 externalUrl: comment.html_url,
434 eventData: {
435 externalCommentId: comment.id,
436 },
437 })
438 .onConflictDoNothing({
439 target: [
440 activityTable.taskId,
441 activityTable.externalSource,
442 activityTable.externalUrl,
443 ],
444 });
445 }
446}
447
448async function linkPullRequestToTask(
449 pr: {
450 number: number;
451 title: string;
452 body: string | null;
453 html_url: string;
454 state: string;
455 head: { ref: string };
456 user?: { login?: string; username?: string; avatar_url?: string } | null;
457 },
458 integrationId: string,
459 projectId: string,
460 projectSlug: string,
461 config: GiteaConfig,
462): Promise<void> {
463 const taskNumber = extractTaskNumberGitea(
464 pr.head.ref,
465 pr.title,
466 pr.body ?? undefined,
467 config,
468 projectSlug,
469 );
470
471 if (!taskNumber) {
472 return;
473 }
474
475 const task = await findTaskByNumber(projectId, taskNumber);
476
477 if (!task) {
478 return;
479 }
480
481 const existingLink = await findExternalLink(
482 integrationId,
483 "pull_request",
484 pr.number.toString(),
485 );
486
487 if (existingLink) {
488 return;
489 }
490
491 await createExternalLink({
492 taskId: task.id,
493 integrationId,
494 resourceType: "pull_request",
495 externalId: pr.number.toString(),
496 url: pr.html_url,
497 title: pr.title,
498 metadata: {
499 state: pr.state,
500 branch: pr.head.ref,
501 author: pr.user?.login ?? pr.user?.username,
502 },
503 });
504}