kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { eq, inArray } from "drizzle-orm";
2import db from "../../../database";
3import { labelTable, taskTable } from "../../../database/schema";
4import { publishEvent } from "../../../events";
5import { findExternalLink } from "../../github/services/link-manager";
6import { updateTaskStatus } from "../../github/services/task-service";
7import {
8 extractIssuePriority,
9 extractIssueStatus,
10} from "../../github/utils/extract-priority";
11import {
12 findAllIntegrationsByGiteaRepo,
13 repoOwnerLogin,
14} from "../services/integration-lookup";
15import { isSystemLabelName } from "../utils/system-labels";
16import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo";
17
18type IssueLabeledPayload = {
19 action: string;
20 issue: {
21 number: number;
22 labels?: Array<string | { name?: string; color?: string }>;
23 };
24 label?: {
25 name: string;
26 color: string;
27 };
28 repository: {
29 owner: { login?: string; username?: string };
30 name: string;
31 html_url: string;
32 };
33};
34
35/** Non-system labels from a Gitea issue (used when action is label_updated). */
36function giteaLabelsForSync(
37 labels: IssueLabeledPayload["issue"]["labels"],
38): Array<{ name: string; color?: string }> {
39 if (!labels) return [];
40 const out: Array<{ name: string; color?: string }> = [];
41 for (const raw of labels) {
42 const name = typeof raw === "string" ? raw : raw.name;
43 if (!name || isSystemLabelName(name)) continue;
44 const color =
45 typeof raw === "object" && raw && "color" in raw ? raw.color : undefined;
46 out.push({ name, color });
47 }
48 return out;
49}
50
51function normalizedGiteaLabelColor(g: { color?: string }): string {
52 return g.color ? `#${g.color.replace(/^#/, "")}` : "#6B7280";
53}
54
55async function syncGiteaLabelsToTask(
56 taskId: string,
57 workspaceId: string,
58 giteaLabels: Array<{ name: string; color?: string }>,
59) {
60 const desiredNames = new Set(giteaLabels.map((l) => l.name));
61 const existingRows = await db.query.labelTable.findMany({
62 where: eq(labelTable.taskId, taskId),
63 });
64
65 const labelsToInsert = giteaLabels
66 .filter((g) => !existingRows.some((row) => row.name === g.name))
67 .map((g) => ({
68 name: g.name,
69 color: normalizedGiteaLabelColor(g),
70 taskId,
71 workspaceId,
72 }));
73
74 const colorToIds = new Map<string, string[]>();
75 for (const g of giteaLabels) {
76 if (isSystemLabelName(g.name)) continue;
77 const row = existingRows.find((r) => r.name === g.name);
78 if (!row) continue;
79 const want = normalizedGiteaLabelColor(g);
80 const have = row.color ? `#${row.color.replace(/^#/, "")}` : "#6B7280";
81 if (have === want) continue;
82 const list = colorToIds.get(want) ?? [];
83 list.push(row.id);
84 colorToIds.set(want, list);
85 }
86
87 for (const [color, ids] of colorToIds) {
88 if (ids.length === 0) continue;
89 await db
90 .update(labelTable)
91 .set({ color })
92 .where(inArray(labelTable.id, ids));
93 }
94
95 if (labelsToInsert.length > 0) {
96 await db
97 .insert(labelTable)
98 .values(labelsToInsert)
99 .onConflictDoNothing({
100 target: [labelTable.taskId, labelTable.name],
101 });
102 }
103
104 const labelsToDelete = existingRows
105 .filter(
106 (row) => !desiredNames.has(row.name) && !isSystemLabelName(row.name),
107 )
108 .map((row) => row.id);
109
110 if (labelsToDelete.length > 0) {
111 await db.delete(labelTable).where(inArray(labelTable.id, labelsToDelete));
112 }
113}
114
115export async function handleGiteaIssueLabeled(payload: IssueLabeledPayload) {
116 const { issue, repository, label: addedLabel } = payload;
117
118 const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url);
119 if (!baseUrl) return;
120
121 const owner = repoOwnerLogin(repository);
122 const integrations = await findAllIntegrationsByGiteaRepo(
123 baseUrl,
124 owner,
125 repository.name,
126 );
127
128 for (const integration of integrations) {
129 try {
130 const existingLink = await findExternalLink(
131 integration.id,
132 "issue",
133 issue.number.toString(),
134 );
135
136 if (!existingLink) {
137 continue;
138 }
139
140 const priority = extractIssuePriority(issue.labels);
141 const status = extractIssueStatus(issue.labels);
142
143 if (priority) {
144 await db
145 .update(taskTable)
146 .set({ priority })
147 .where(eq(taskTable.id, existingLink.taskId));
148 }
149
150 if (status) {
151 const statusResult = await updateTaskStatus(
152 existingLink.taskId,
153 status,
154 );
155 if (
156 statusResult.applied &&
157 statusResult.before.status !== statusResult.after.status
158 ) {
159 await publishEvent("task.status_changed", {
160 taskId: statusResult.after.id,
161 projectId: statusResult.after.projectId,
162 userId: null,
163 oldStatus: statusResult.before.status,
164 newStatus: statusResult.after.status,
165 title: statusResult.after.title,
166 assigneeId: statusResult.after.userId,
167 type: "status_changed",
168 });
169 }
170 }
171
172 if (payload.action === "label_updated") {
173 if (issue.labels === undefined) {
174 continue;
175 }
176
177 const task = await db.query.taskTable.findFirst({
178 where: eq(taskTable.id, existingLink.taskId),
179 with: {
180 project: true,
181 },
182 });
183 if (task?.project?.workspaceId) {
184 await syncGiteaLabelsToTask(
185 existingLink.taskId,
186 task.project.workspaceId,
187 giteaLabelsForSync(issue.labels),
188 );
189 }
190 continue;
191 }
192
193 if (!addedLabel) {
194 continue;
195 }
196
197 if (isSystemLabelName(addedLabel.name)) {
198 continue;
199 }
200
201 if (payload.action === "labeled") {
202 const task = await db.query.taskTable.findFirst({
203 where: eq(taskTable.id, existingLink.taskId),
204 with: {
205 project: true,
206 },
207 });
208
209 if (task?.project?.workspaceId) {
210 const existingLabel = await db.query.labelTable.findFirst({
211 where: (table, { and, eq: e }) =>
212 and(
213 e(table.workspaceId, task.project.workspaceId),
214 e(table.name, addedLabel.name),
215 e(table.taskId, task.id),
216 ),
217 });
218
219 if (!existingLabel) {
220 const color = addedLabel.color
221 ? `#${addedLabel.color.replace(/^#/, "")}`
222 : "#6B7280";
223 await db
224 .insert(labelTable)
225 .values({
226 name: addedLabel.name,
227 color,
228 taskId: task.id,
229 workspaceId: task.project.workspaceId,
230 })
231 .onConflictDoNothing({
232 target: [labelTable.taskId, labelTable.name],
233 });
234 }
235 }
236 }
237
238 if (payload.action === "unlabeled") {
239 const labelsToDelete = await db.query.labelTable.findMany({
240 where: (table, { and, eq: e }) =>
241 and(
242 e(table.taskId, existingLink.taskId),
243 e(table.name, addedLabel.name),
244 ),
245 });
246
247 for (const label of labelsToDelete) {
248 await db.delete(labelTable).where(eq(labelTable.id, label.id));
249 }
250 }
251 } catch (error) {
252 console.error("Gitea issue_labeled handler failed for integration", {
253 integrationId: integration.id,
254 issueNumber: issue.number,
255 repository: `${owner}/${repository.name}`,
256 error,
257 });
258 }
259 }
260}