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 IssueReopenedPayload = {
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 handleGiteaIssueReopened(payload: IssueReopenedPayload) {
35 if (payload.action !== "reopened") {
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 try {
53 const externalLink = await db.query.externalLinkTable.findFirst({
54 where: and(
55 eq(externalLinkTable.integrationId, integration.id),
56 eq(externalLinkTable.resourceType, "issue"),
57 eq(externalLinkTable.externalId, issue.number.toString()),
58 ),
59 });
60
61 if (!externalLink) {
62 continue;
63 }
64
65 const task = await db.query.taskTable.findFirst({
66 where: eq(taskTable.id, externalLink.taskId),
67 });
68
69 if (!task) {
70 continue;
71 }
72
73 let existingMetadata: Record<string, unknown> = {};
74 if (externalLink.metadata) {
75 try {
76 existingMetadata = JSON.parse(externalLink.metadata) as Record<
77 string,
78 unknown
79 >;
80 } catch (error) {
81 console.warn("Failed to parse Gitea issue metadata for reopen sync", {
82 externalLinkId: externalLink.id,
83 metadata: externalLink.metadata,
84 error,
85 });
86 }
87 }
88
89 const lastOutbound = existingMetadata.lastOutboundStateSyncAt;
90 if (typeof lastOutbound === "number" && Number.isFinite(lastOutbound)) {
91 const eventMs = parseIssueUpdatedAtMs(issue);
92 if (
93 eventMs !== null &&
94 Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS
95 ) {
96 continue;
97 }
98 }
99
100 const targetStatus = await resolveTargetStatus(
101 task.projectId,
102 "issue_reopened",
103 "to-do",
104 );
105
106 const statusResult = await updateTaskStatus(task.id, targetStatus);
107 if (
108 statusResult.applied &&
109 statusResult.before.status !== statusResult.after.status
110 ) {
111 await publishEvent("task.status_changed", {
112 taskId: statusResult.after.id,
113 projectId: statusResult.after.projectId,
114 userId: null,
115 oldStatus: statusResult.before.status,
116 newStatus: statusResult.after.status,
117 title: statusResult.after.title,
118 assigneeId: statusResult.after.userId,
119 type: "status_changed",
120 });
121 }
122
123 await updateExternalLink(externalLink.id, {
124 metadata: {
125 ...existingMetadata,
126 state: "open",
127 },
128 });
129 } catch (error) {
130 console.error("Gitea issue_reopened handler failed for integration", {
131 integrationId: integration.id,
132 issueNumber: issue.number,
133 repository: `${owner}/${repository.name}`,
134 error,
135 });
136 }
137 }
138}