kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
at main 359 lines 10 kB view raw
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;