kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq } from "drizzle-orm";
2import type { Context } from "hono";
3import { Hono } from "hono";
4import { HTTPException } from "hono/http-exception";
5import { describeRoute, resolver, validator } from "hono-openapi";
6import * as v from "valibot";
7import db from "../database";
8import { integrationTable, projectTable } from "../database/schema";
9import { type GiteaConfig, validateGiteaConfig } from "../plugins/gitea/config";
10import { handleGiteaWebhookRequest } from "../plugins/gitea/webhook-handler";
11import { giteaIntegrationSchema } from "../schemas";
12import { validateWorkspaceAccess } from "../utils/validate-workspace-access";
13import { workspaceAccess } from "../utils/workspace-access-middleware";
14import createGiteaIntegration from "./controllers/create-gitea-integration";
15import deleteGiteaIntegration from "./controllers/delete-gitea-integration";
16import getGiteaIntegration from "./controllers/get-gitea-integration";
17import { importGiteaIssues } from "./controllers/import-gitea-issues";
18import listGiteaRepositories from "./controllers/list-gitea-repositories";
19import verifyGiteaAccess from "./controllers/verify-gitea-access";
20
21const giteaRepositorySchema = v.object({
22 id: v.number(),
23 name: v.string(),
24 full_name: v.string(),
25 owner: v.object({
26 login: v.string(),
27 }),
28 private: v.boolean(),
29 html_url: v.string(),
30});
31
32const verificationResultSchema = v.object({
33 isInstalled: v.boolean(),
34 hasRequiredPermissions: v.boolean(),
35 repositoryExists: v.boolean(),
36 repositoryPrivate: v.nullable(v.boolean()),
37 missingPermissions: v.array(v.string()),
38 message: v.string(),
39});
40
41const importResultSchema = v.object({
42 imported: v.number(),
43 updated: v.number(),
44 skipped: v.number(),
45 errors: v.optional(v.array(v.string())),
46});
47
48const nullableGiteaIntegrationSchema = v.nullable(giteaIntegrationSchema);
49
50const giteaIntegration = new Hono<{
51 Variables: {
52 userId: string;
53 workspaceId: string;
54 apiKey?: {
55 id: string;
56 userId: string;
57 enabled: boolean;
58 };
59 };
60}>()
61 .post(
62 "/repositories",
63 describeRoute({
64 operationId: "listGiteaRepositories",
65 tags: ["Gitea"],
66 description: "List repositories accessible with a Gitea token",
67 responses: {
68 200: {
69 description: "Repositories",
70 content: {
71 "application/json": {
72 schema: resolver(
73 v.object({
74 repositories: v.array(giteaRepositorySchema),
75 }),
76 ),
77 },
78 },
79 },
80 },
81 }),
82 validator(
83 "json",
84 v.object({
85 baseUrl: v.pipe(v.string(), v.minLength(1)),
86 accessToken: v.pipe(v.string(), v.minLength(1)),
87 }),
88 ),
89 async (c) => {
90 const { baseUrl, accessToken } = c.req.valid("json");
91 const result = await listGiteaRepositories({ baseUrl, accessToken });
92 return c.json(result);
93 },
94 )
95 .post(
96 "/verify",
97 describeRoute({
98 operationId: "verifyGiteaAccess",
99 tags: ["Gitea"],
100 description: "Verify Gitea token and repository access",
101 responses: {
102 200: {
103 description: "Verification result",
104 content: {
105 "application/json": {
106 schema: resolver(verificationResultSchema),
107 },
108 },
109 },
110 },
111 }),
112 validator(
113 "json",
114 v.object({
115 baseUrl: v.pipe(v.string(), v.minLength(1)),
116 accessToken: v.pipe(v.string(), v.minLength(1)),
117 repositoryOwner: v.pipe(v.string(), v.minLength(1)),
118 repositoryName: v.pipe(v.string(), v.minLength(1)),
119 }),
120 ),
121 async (c) => {
122 const body = c.req.valid("json");
123 const result = await verifyGiteaAccess(body);
124 return c.json(result);
125 },
126 )
127 .get(
128 "/project/:projectId",
129 describeRoute({
130 operationId: "getGiteaIntegration",
131 tags: ["Gitea"],
132 description: "Get Gitea integration for a project",
133 responses: {
134 200: {
135 description: "Gitea integration details",
136 content: {
137 "application/json": {
138 schema: resolver(nullableGiteaIntegrationSchema),
139 },
140 },
141 },
142 },
143 }),
144 validator("param", v.object({ projectId: v.string() })),
145 workspaceAccess.fromProject("projectId"),
146 async (c) => {
147 const { projectId } = c.req.valid("param");
148 const integration = await getGiteaIntegration(projectId);
149 if (!integration) {
150 return c.json(null, 200);
151 }
152 return c.json(integration);
153 },
154 )
155 .post(
156 "/project/:projectId",
157 describeRoute({
158 operationId: "createGiteaIntegration",
159 tags: ["Gitea"],
160 description: "Create or update Gitea integration for a project",
161 responses: {
162 200: {
163 description: "Integration saved",
164 content: {
165 "application/json": {
166 schema: resolver(giteaIntegrationSchema),
167 },
168 },
169 },
170 },
171 }),
172 validator("param", v.object({ projectId: v.string() })),
173 validator(
174 "json",
175 v.object({
176 baseUrl: v.pipe(v.string(), v.minLength(1)),
177 accessToken: v.optional(v.string()),
178 repositoryOwner: v.pipe(v.string(), v.minLength(1)),
179 repositoryName: v.pipe(v.string(), v.minLength(1)),
180 }),
181 ),
182 workspaceAccess.fromProject("projectId"),
183 async (c) => {
184 const { projectId } = c.req.valid("param");
185 const body = c.req.valid("json");
186 await createGiteaIntegration({
187 projectId,
188 baseUrl: body.baseUrl,
189 accessToken: body.accessToken,
190 repositoryOwner: body.repositoryOwner,
191 repositoryName: body.repositoryName,
192 });
193 const integration = await getGiteaIntegration(projectId);
194 if (!integration) {
195 throw new HTTPException(500, { message: "Failed to load integration" });
196 }
197 return c.json(integration);
198 },
199 )
200 .patch(
201 "/project/:projectId",
202 describeRoute({
203 operationId: "updateGiteaIntegration",
204 tags: ["Gitea"],
205 description: "Update Gitea integration settings",
206 responses: {
207 200: {
208 description: "Updated",
209 content: {
210 "application/json": {
211 schema: resolver(giteaIntegrationSchema),
212 },
213 },
214 },
215 },
216 }),
217 validator("param", v.object({ projectId: v.string() })),
218 validator(
219 "json",
220 v.object({
221 isActive: v.optional(v.boolean()),
222 commentTaskLinkOnGiteaIssue: v.optional(v.boolean()),
223 }),
224 ),
225 workspaceAccess.fromProject("projectId"),
226 async (c) => {
227 const { projectId } = c.req.valid("param");
228 const body = c.req.valid("json");
229
230 const row = await db.query.integrationTable.findFirst({
231 where: and(
232 eq(integrationTable.projectId, projectId),
233 eq(integrationTable.type, "gitea"),
234 ),
235 });
236
237 if (!row) {
238 return c.json({ error: "Integration not found" }, 404);
239 }
240
241 let config: GiteaConfig;
242 try {
243 config = JSON.parse(row.config) as GiteaConfig;
244 } catch {
245 throw new HTTPException(500, { message: "Invalid integration config" });
246 }
247
248 if (body.commentTaskLinkOnGiteaIssue !== undefined) {
249 config = {
250 ...config,
251 commentTaskLinkOnGiteaIssue: body.commentTaskLinkOnGiteaIssue,
252 };
253 }
254
255 const validation = await validateGiteaConfig(config);
256 if (!validation.valid) {
257 throw new HTTPException(400, {
258 message: validation.errors?.join(", ") ?? "Invalid config",
259 });
260 }
261
262 await db
263 .update(integrationTable)
264 .set({
265 config: JSON.stringify(config),
266 isActive:
267 body.isActive !== undefined
268 ? body.isActive
269 : (row.isActive ?? true),
270 updatedAt: new Date(),
271 })
272 .where(
273 and(
274 eq(integrationTable.projectId, projectId),
275 eq(integrationTable.type, "gitea"),
276 ),
277 );
278
279 const updated = await getGiteaIntegration(projectId);
280 if (!updated) {
281 throw new HTTPException(500, { message: "Failed to load integration" });
282 }
283 return c.json(updated, 200);
284 },
285 )
286 .delete(
287 "/project/:projectId",
288 describeRoute({
289 operationId: "deleteGiteaIntegration",
290 tags: ["Gitea"],
291 description: "Delete Gitea integration for a project",
292 responses: {
293 200: {
294 description: "Deleted",
295 content: {
296 "application/json": {
297 schema: resolver(
298 v.object({
299 success: v.boolean(),
300 message: v.string(),
301 }),
302 ),
303 },
304 },
305 },
306 },
307 }),
308 validator("param", v.object({ projectId: v.string() })),
309 workspaceAccess.fromProject("projectId"),
310 async (c) => {
311 const { projectId } = c.req.valid("param");
312 const result = await deleteGiteaIntegration(projectId);
313 return c.json(result);
314 },
315 )
316 .post(
317 "/import-issues",
318 describeRoute({
319 operationId: "importGiteaIssues",
320 tags: ["Gitea"],
321 description: "Import Gitea issues as tasks",
322 responses: {
323 200: {
324 description: "Import result",
325 content: {
326 "application/json": {
327 schema: resolver(importResultSchema),
328 },
329 },
330 },
331 },
332 }),
333 validator(
334 "json",
335 v.object({
336 projectId: v.string(),
337 }),
338 ),
339 async (c, next) => {
340 const userId = c.get("userId");
341 if (!userId) {
342 throw new HTTPException(401, { message: "Unauthorized" });
343 }
344
345 const { projectId } = c.req.valid("json");
346
347 const [project] = await db
348 .select({ workspaceId: projectTable.workspaceId })
349 .from(projectTable)
350 .where(eq(projectTable.id, projectId))
351 .limit(1);
352
353 if (!project) {
354 throw new HTTPException(404, { message: "Project not found" });
355 }
356
357 const apiKey = c.get("apiKey");
358 const apiKeyId = apiKey?.id;
359
360 await validateWorkspaceAccess(userId, project.workspaceId, apiKeyId);
361 c.set("workspaceId", project.workspaceId);
362
363 return next();
364 },
365 async (c) => {
366 const { projectId } = c.req.valid("json");
367 const result = await importGiteaIssues(projectId);
368 return c.json(result);
369 },
370 );
371
372export async function handleGiteaWebhookRoute(c: Context) {
373 const integrationId = c.req.param("integrationId");
374 if (!integrationId) {
375 return c.json({ error: "Missing integration id" }, 400);
376 }
377
378 const arrayBuffer = await c.req.arrayBuffer();
379 const body = Buffer.from(arrayBuffer).toString("utf8");
380
381 const signature =
382 c.req.header("x-gitea-signature") || c.req.header("X-Gitea-Signature");
383
384 const eventName =
385 c.req.header("x-gitea-event") ||
386 c.req.header("X-Gitea-Event") ||
387 c.req.header("x-github-event");
388
389 const result = await handleGiteaWebhookRequest(
390 integrationId,
391 body,
392 signature,
393 eventName,
394 );
395
396 if (!result.success) {
397 return c.json({ error: result.error }, 400);
398 }
399
400 return c.json({ status: "success" });
401}
402
403export default giteaIntegration;