kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq } from "drizzle-orm";
2import db from "../../../database";
3import { columnTable, projectTable, taskTable } from "../../../database/schema";
4import { publishEvent } from "../../../events";
5import getNextTaskNumber from "../../../task/controllers/get-next-task-number";
6import {
7 createExternalLink,
8 findExternalLink,
9} from "../../github/services/link-manager";
10import {
11 extractIssuePriority,
12 extractIssueStatus,
13} from "../../github/utils/extract-priority";
14import { formatTaskDescriptionFromIssue } from "../../github/utils/format";
15import type { GiteaConfig } from "../config";
16import {
17 findAllIntegrationsByGiteaRepo,
18 repoOwnerLogin,
19} from "../services/integration-lookup";
20import { createGiteaClient } from "../utils/gitea-api";
21import { addLabelsToIssueGitea } from "../utils/labels";
22import { resolveTargetStatus } from "../utils/resolve-column";
23import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo";
24
25type IssueOpenedPayload = {
26 action: string;
27 issue: {
28 number: number;
29 title: string;
30 body: string | null;
31 html_url: string;
32 labels?: Array<string | { name?: string }>;
33 user: { login?: string; username?: string } | null;
34 };
35 repository: {
36 owner: { login?: string; username?: string };
37 name: string;
38 html_url: string;
39 };
40};
41
42export async function handleGiteaIssueOpened(payload: IssueOpenedPayload) {
43 const { issue, repository } = payload;
44
45 const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url);
46 if (!baseUrl) {
47 return;
48 }
49
50 const owner = repoOwnerLogin(repository);
51 const integrations = await findAllIntegrationsByGiteaRepo(
52 baseUrl,
53 owner,
54 repository.name,
55 );
56
57 if (integrations.length === 0) {
58 return;
59 }
60
61 for (const integration of integrations) {
62 let config: GiteaConfig;
63 try {
64 config = JSON.parse(integration.config) as GiteaConfig;
65 } catch (error) {
66 console.error("Invalid Gitea config for integration", {
67 integrationId: integration.id,
68 error,
69 });
70 continue;
71 }
72 const projectId = integration.projectId;
73
74 const priority = extractIssuePriority(issue.labels);
75 const status = extractIssueStatus(issue.labels);
76
77 const existingLink = await findExternalLink(
78 integration.id,
79 "issue",
80 issue.number.toString(),
81 );
82
83 if (existingLink) {
84 continue;
85 }
86
87 const nextTaskNumber = await getNextTaskNumber(projectId);
88
89 const resolvedStatus = await resolveTargetStatus(
90 projectId,
91 "issue_opened",
92 status || "to-do",
93 );
94
95 const targetColumn = await db.query.columnTable.findFirst({
96 where: and(
97 eq(columnTable.projectId, projectId),
98 eq(columnTable.slug, resolvedStatus),
99 ),
100 });
101
102 const taskValues: typeof taskTable.$inferInsert = {
103 projectId,
104 userId: null,
105 title: issue.title,
106 description: formatTaskDescriptionFromIssue(issue.body),
107 status: resolvedStatus,
108 columnId: targetColumn?.id ?? null,
109 priority: null,
110 number: nextTaskNumber + 1,
111 };
112
113 if (priority) taskValues.priority = priority;
114
115 const [createdTask] = await db
116 .insert(taskTable)
117 .values(taskValues)
118 .returning();
119
120 if (!createdTask) {
121 console.error("Failed to create task from Gitea issue");
122 continue;
123 }
124
125 await publishEvent("task.created", {
126 ...createdTask,
127 taskId: createdTask.id,
128 userId: createdTask.userId ?? "",
129 type: "task",
130 content: null,
131 source: "gitea",
132 externalId: issue.number.toString(),
133 actor: issue.user?.login ?? issue.user?.username ?? "gitea-webhook",
134 });
135
136 await createExternalLink({
137 taskId: createdTask.id,
138 integrationId: integration.id,
139 resourceType: "issue",
140 externalId: issue.number.toString(),
141 url: issue.html_url,
142 title: issue.title,
143 metadata: {
144 state: "open",
145 createdFrom: "gitea",
146 author: issue.user?.login ?? issue.user?.username,
147 },
148 });
149
150 const project = await db.query.projectTable.findFirst({
151 where: eq(projectTable.id, projectId),
152 });
153
154 if (!project) {
155 continue;
156 }
157
158 const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173";
159 const taskUrl = `${clientUrl}/dashboard/workspace/${project.workspaceId}/project/${projectId}/task/${createdTask.id}`;
160 const taskIdentifier = `${project.slug.toUpperCase()}-${createdTask.number}`;
161
162 try {
163 const client = createGiteaClient(config);
164
165 const existingLabels =
166 issue.labels
167 ?.map((label) => (typeof label === "string" ? label : label.name))
168 .filter(Boolean) || [];
169
170 const labelsToAdd: string[] = [];
171
172 if (priority && !existingLabels.includes(`priority:${priority}`)) {
173 labelsToAdd.push(`priority:${priority}`);
174 }
175
176 if (status && !existingLabels.includes(`status:${status}`)) {
177 labelsToAdd.push(`status:${status}`);
178 }
179
180 if (labelsToAdd.length > 0) {
181 await addLabelsToIssueGitea(config, issue.number, labelsToAdd);
182 }
183
184 if (config.commentTaskLinkOnGiteaIssue !== false) {
185 await client.createIssueComment(
186 config.repositoryOwner,
187 config.repositoryName,
188 issue.number,
189 `[${taskIdentifier}](${taskUrl})`,
190 );
191 }
192 } catch (error) {
193 console.error("Failed to process Gitea issue:", error);
194 }
195 }
196}