kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { eq } from "drizzle-orm";
2import db from "../../database";
3import { integrationTable } from "../../database/schema";
4import type { GiteaConfig } from "./config";
5import { verifyGiteaSignature } from "./utils/verify-signature";
6import { handleGiteaIssueClosed } from "./webhooks/issue-closed";
7import { handleGiteaIssueCommentCreated } from "./webhooks/issue-comment-created";
8import { handleGiteaIssueEdited } from "./webhooks/issue-edited";
9import { handleGiteaIssueLabeled } from "./webhooks/issue-labeled";
10import { handleGiteaIssueOpened } from "./webhooks/issue-opened";
11import { handleGiteaIssueReopened } from "./webhooks/issue-reopened";
12import { handleGiteaLabelCreated } from "./webhooks/label-created";
13import { handleGiteaPullRequestClosed } from "./webhooks/pull-request-closed";
14import { handleGiteaPullRequestOpened } from "./webhooks/pull-request-opened";
15import { handleGiteaPush } from "./webhooks/push";
16
17type GiteaPushPayload = Parameters<typeof handleGiteaPush>[0];
18type GiteaPullRequestPayload = Parameters<
19 typeof handleGiteaPullRequestOpened
20>[0];
21type GiteaPullRequestClosedPayload = Parameters<
22 typeof handleGiteaPullRequestClosed
23>[0];
24type GiteaIssuePayload = Parameters<typeof handleGiteaIssueOpened>[0];
25type GiteaIssueClosedPayload = Parameters<typeof handleGiteaIssueClosed>[0];
26type GiteaIssueReopenedPayload = Parameters<typeof handleGiteaIssueReopened>[0];
27type GiteaIssueCommentPayload = Parameters<
28 typeof handleGiteaIssueCommentCreated
29>[0];
30type GiteaLabelPayload = Parameters<typeof handleGiteaLabelCreated>[0];
31
32function isRecord(value: unknown): value is Record<string, unknown> {
33 return typeof value === "object" && value !== null;
34}
35
36function hasRepository(value: Record<string, unknown>) {
37 return isRecord(value.repository);
38}
39
40function isPushPayload(
41 payload: Record<string, unknown>,
42): payload is GiteaPushPayload {
43 return typeof payload.ref === "string" && hasRepository(payload);
44}
45
46function isPullRequestPayload(
47 payload: Record<string, unknown>,
48): payload is GiteaPullRequestPayload {
49 return hasRepository(payload) && isRecord(payload.pull_request);
50}
51
52function isIssuePayload(
53 payload: Record<string, unknown>,
54): payload is GiteaIssuePayload {
55 return hasRepository(payload) && isRecord(payload.issue);
56}
57
58function isIssueCommentPayload(
59 payload: Record<string, unknown>,
60): payload is GiteaIssueCommentPayload {
61 return (
62 hasRepository(payload) &&
63 isRecord(payload.issue) &&
64 isRecord(payload.comment)
65 );
66}
67
68function isLabelPayload(
69 payload: Record<string, unknown>,
70): payload is GiteaLabelPayload {
71 return hasRepository(payload);
72}
73
74export async function handleGiteaWebhookRequest(
75 integrationId: string,
76 rawBody: string,
77 signatureHeader: string | undefined,
78 eventHeader: string | undefined,
79): Promise<{ success: boolean; error?: string }> {
80 const integration = await db.query.integrationTable.findFirst({
81 where: eq(integrationTable.id, integrationId),
82 });
83
84 if (!integration || integration.type !== "gitea") {
85 return { success: false, error: "Gitea integration not found" };
86 }
87
88 let config: GiteaConfig;
89 try {
90 config = JSON.parse(integration.config) as GiteaConfig;
91 } catch {
92 return { success: false, error: "Invalid integration config" };
93 }
94
95 const secret = config.webhookSecret;
96 if (!secret) {
97 return { success: false, error: "Webhook secret not configured" };
98 }
99
100 if (!verifyGiteaSignature(rawBody, secret, signatureHeader)) {
101 return { success: false, error: "Invalid webhook signature" };
102 }
103
104 const event = eventHeader || undefined;
105
106 if (!event) {
107 return { success: false, error: "Missing event name" };
108 }
109
110 let payload: Record<string, unknown>;
111 try {
112 payload = JSON.parse(rawBody) as Record<string, unknown>;
113 } catch {
114 return { success: false, error: "Invalid JSON payload" };
115 }
116
117 try {
118 await dispatchGiteaEvent(event, payload);
119 return { success: true };
120 } catch (error) {
121 console.error("[Gitea Webhook] Handler error:", error);
122 return {
123 success: false,
124 error: error instanceof Error ? error.message : "Webhook handler failed",
125 };
126 }
127}
128
129async function dispatchGiteaEvent(
130 event: string,
131 payload: Record<string, unknown>,
132) {
133 console.log(`[Gitea Webhook] Event: ${event}`);
134
135 switch (event) {
136 case "push":
137 if (isPushPayload(payload)) {
138 await handleGiteaPush(payload);
139 }
140 return;
141 case "pull_request": {
142 const action = payload.action as string | undefined;
143 if (
144 action === "opened" ||
145 action === "reopened" ||
146 action === "ready_for_review"
147 ) {
148 if (isPullRequestPayload(payload)) {
149 await handleGiteaPullRequestOpened(payload);
150 }
151 } else if (action === "closed" && isPullRequestPayload(payload)) {
152 await handleGiteaPullRequestClosed(
153 payload as unknown as GiteaPullRequestClosedPayload,
154 );
155 }
156 return;
157 }
158 case "issues": {
159 const action = payload.action as string | undefined;
160 // Gitea uses "created" for new issues; GitHub-style is "opened"
161 if (
162 (action === "opened" || action === "created") &&
163 isIssuePayload(payload)
164 ) {
165 await handleGiteaIssueOpened(payload);
166 } else if (action === "reopened" && isIssuePayload(payload)) {
167 await handleGiteaIssueReopened(
168 payload as unknown as GiteaIssueReopenedPayload,
169 );
170 } else if (action === "closed" && isIssuePayload(payload)) {
171 await handleGiteaIssueClosed(
172 payload as unknown as GiteaIssueClosedPayload,
173 );
174 } else if (action === "edited" && isIssuePayload(payload)) {
175 await handleGiteaIssueEdited(payload);
176 } else if (
177 isIssuePayload(payload) &&
178 (action === "labeled" ||
179 action === "unlabeled" ||
180 action === "label_updated")
181 ) {
182 await handleGiteaIssueLabeled({
183 ...payload,
184 action: action ?? "",
185 });
186 }
187 return;
188 }
189 case "issue_comment": {
190 const action = payload.action as string | undefined;
191 if (action === "created" && isIssueCommentPayload(payload)) {
192 await handleGiteaIssueCommentCreated(payload);
193 }
194 return;
195 }
196 case "issue_label": {
197 if (isLabelPayload(payload)) {
198 await handleGiteaLabelCreated(payload);
199 }
200 return;
201 }
202 default:
203 console.log(`[Gitea Webhook] Ignored event: ${event}`);
204 }
205}