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 { externalLinkTable, taskTable } from "../../../database/schema";
4import { publishEvent } from "../../../events";
5import { updateExternalLink } from "../../github/services/link-manager";
6import { updateTaskStatus } from "../../github/services/task-service";
7import {
8 findAllIntegrationsByGiteaRepo,
9 repoOwnerLogin,
10} from "../services/integration-lookup";
11import {
12 OUTBOUND_STATE_ECHO_WINDOW_MS,
13 parseIssueUpdatedAtMs,
14} from "../utils/outbound-echo";
15import { resolveTargetStatus } from "../utils/resolve-column";
16import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo";
17
18type IssueClosedPayload = {
19 action: string;
20 issue: {
21 number: number;
22 title: string;
23 html_url: string;
24 state: string;
25 updated_at?: string;
26 };
27 repository: {
28 owner: { login?: string; username?: string };
29 name: string;
30 html_url: string;
31 };
32};
33
34export async function handleGiteaIssueClosed(payload: IssueClosedPayload) {
35 if (payload.action !== "closed") {
36 return;
37 }
38
39 const { issue, repository } = payload;
40
41 const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url);
42 if (!baseUrl) return;
43
44 const owner = repoOwnerLogin(repository);
45 const integrations = await findAllIntegrationsByGiteaRepo(
46 baseUrl,
47 owner,
48 repository.name,
49 );
50
51 for (const integration of integrations) {
52 const externalLink = await db.query.externalLinkTable.findFirst({
53 where: and(
54 eq(externalLinkTable.integrationId, integration.id),
55 eq(externalLinkTable.resourceType, "issue"),
56 eq(externalLinkTable.externalId, issue.number.toString()),
57 ),
58 });
59
60 if (!externalLink) {
61 continue;
62 }
63
64 const task = await db.query.taskTable.findFirst({
65 where: eq(taskTable.id, externalLink.taskId),
66 });
67
68 if (!task) {
69 continue;
70 }
71
72 let existingMetadata: Record<string, unknown> = {};
73 if (externalLink.metadata) {
74 try {
75 existingMetadata = JSON.parse(externalLink.metadata) as Record<
76 string,
77 unknown
78 >;
79 } catch (error) {
80 console.warn("Failed to parse Gitea issue metadata for close sync", {
81 externalLinkId: externalLink.id,
82 metadata: externalLink.metadata,
83 error,
84 });
85 }
86 }
87
88 const lastOutbound = existingMetadata.lastOutboundStateSyncAt;
89 if (typeof lastOutbound === "number" && Number.isFinite(lastOutbound)) {
90 const eventMs = parseIssueUpdatedAtMs(issue);
91 if (
92 eventMs !== null &&
93 Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS
94 ) {
95 continue;
96 }
97 }
98
99 const targetStatus = await resolveTargetStatus(
100 task.projectId,
101 "issue_closed",
102 "done",
103 );
104
105 const statusResult = await updateTaskStatus(task.id, targetStatus);
106 if (
107 statusResult.applied &&
108 statusResult.before.status !== statusResult.after.status
109 ) {
110 await publishEvent("task.status_changed", {
111 taskId: statusResult.after.id,
112 projectId: statusResult.after.projectId,
113 userId: null,
114 oldStatus: statusResult.before.status,
115 newStatus: statusResult.after.status,
116 title: statusResult.after.title,
117 assigneeId: statusResult.after.userId,
118 type: "status_changed",
119 });
120 }
121
122 await updateExternalLink(externalLink.id, {
123 metadata: {
124 ...existingMetadata,
125 state: "closed",
126 },
127 });
128 }
129}