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 9a620ba2f31238f03cd28f1da5ef3838d67e4e8a 403 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 { 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;