kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cd7cada2f86b4e866a15b4323bb8d6d7ab5bba8b 260 lines 7.5 kB view raw
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}