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