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