kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cd7cada2f86b4e866a15b4323bb8d6d7ab5bba8b 392 lines 11 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 { 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;