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.

feat(api): add Gitea integration REST API

Tin 17782774 77e7836f

+1229
+169
apps/api/src/gitea-integration/controllers/create-gitea-integration.ts
··· 1 + import { randomBytes } from "node:crypto"; 2 + import { and, eq } from "drizzle-orm"; 3 + import { HTTPException } from "hono/http-exception"; 4 + import db from "../../database"; 5 + import { integrationTable, projectTable } from "../../database/schema"; 6 + import { 7 + type GiteaConfig, 8 + getDefaultGiteaConfig, 9 + normalizeGiteaBaseUrl, 10 + validateGiteaConfig, 11 + } from "../../plugins/gitea/config"; 12 + import { 13 + createGiteaClient, 14 + verifyGiteaToken, 15 + } from "../../plugins/gitea/utils/gitea-api"; 16 + 17 + async function createGiteaIntegration({ 18 + projectId, 19 + baseUrl, 20 + accessToken, 21 + repositoryOwner, 22 + repositoryName, 23 + }: { 24 + projectId: string; 25 + baseUrl: string; 26 + accessToken: string | undefined; 27 + repositoryOwner: string; 28 + repositoryName: string; 29 + }) { 30 + const project = await db.query.projectTable.findFirst({ 31 + where: eq(projectTable.id, projectId), 32 + }); 33 + 34 + if (!project) { 35 + throw new HTTPException(404, { message: "Project not found" }); 36 + } 37 + 38 + const normalizedBase = normalizeGiteaBaseUrl(baseUrl); 39 + 40 + const existingIntegration = await db.query.integrationTable.findFirst({ 41 + where: and( 42 + eq(integrationTable.projectId, projectId), 43 + eq(integrationTable.type, "gitea"), 44 + ), 45 + }); 46 + 47 + let resolvedToken = accessToken?.trim() ?? ""; 48 + if (!resolvedToken && existingIntegration) { 49 + const prev = JSON.parse(existingIntegration.config) as GiteaConfig; 50 + resolvedToken = prev.accessToken; 51 + } 52 + 53 + if (!resolvedToken) { 54 + throw new HTTPException(400, { 55 + message: "Personal access token is required", 56 + }); 57 + } 58 + 59 + await verifyGiteaToken(normalizedBase, resolvedToken); 60 + 61 + const client = createGiteaClient({ 62 + baseUrl: normalizedBase, 63 + accessToken: resolvedToken, 64 + }); 65 + await client.getRepo(repositoryOwner, repositoryName); 66 + 67 + const allGitea = await db.query.integrationTable.findMany({ 68 + where: eq(integrationTable.type, "gitea"), 69 + }); 70 + 71 + for (const integration of allGitea) { 72 + if (integration.projectId === projectId) { 73 + continue; 74 + } 75 + try { 76 + const cfg = JSON.parse(integration.config) as { 77 + baseUrl?: string; 78 + repositoryOwner?: string; 79 + repositoryName?: string; 80 + }; 81 + if ( 82 + normalizeGiteaBaseUrl(cfg.baseUrl ?? "") === normalizedBase && 83 + cfg.repositoryOwner === repositoryOwner && 84 + cfg.repositoryName === repositoryName 85 + ) { 86 + throw new HTTPException(409, { 87 + message: `Repository ${repositoryOwner}/${repositoryName} on this Gitea instance is already linked to another project`, 88 + }); 89 + } 90 + } catch (error) { 91 + if (error instanceof HTTPException) { 92 + throw error; 93 + } 94 + } 95 + } 96 + 97 + const webhookSecret = existingIntegration 98 + ? ((JSON.parse(existingIntegration.config) as GiteaConfig).webhookSecret ?? 99 + randomBytes(24).toString("hex")) 100 + : randomBytes(24).toString("hex"); 101 + 102 + const config: GiteaConfig = getDefaultGiteaConfig( 103 + normalizedBase, 104 + resolvedToken, 105 + repositoryOwner, 106 + repositoryName, 107 + webhookSecret, 108 + ); 109 + 110 + const validation = await validateGiteaConfig(config); 111 + if (!validation.valid) { 112 + throw new HTTPException(400, { 113 + message: validation.errors?.join(", ") ?? "Invalid config", 114 + }); 115 + } 116 + 117 + if (existingIntegration) { 118 + const [updated] = await db 119 + .update(integrationTable) 120 + .set({ 121 + config: JSON.stringify(config), 122 + isActive: true, 123 + updatedAt: new Date(), 124 + }) 125 + .where( 126 + and( 127 + eq(integrationTable.projectId, projectId), 128 + eq(integrationTable.type, "gitea"), 129 + ), 130 + ) 131 + .returning(); 132 + 133 + return { 134 + id: updated?.id, 135 + projectId: updated?.projectId, 136 + baseUrl: normalizedBase, 137 + repositoryOwner, 138 + repositoryName, 139 + webhookSecret, 140 + isActive: updated?.isActive, 141 + createdAt: updated?.createdAt, 142 + updatedAt: updated?.updatedAt, 143 + }; 144 + } 145 + 146 + const [newIntegration] = await db 147 + .insert(integrationTable) 148 + .values({ 149 + projectId, 150 + type: "gitea", 151 + config: JSON.stringify(config), 152 + isActive: true, 153 + }) 154 + .returning(); 155 + 156 + return { 157 + id: newIntegration?.id, 158 + projectId: newIntegration?.projectId, 159 + baseUrl: normalizedBase, 160 + repositoryOwner, 161 + repositoryName, 162 + webhookSecret, 163 + isActive: newIntegration?.isActive, 164 + createdAt: newIntegration?.createdAt, 165 + updatedAt: newIntegration?.updatedAt, 166 + }; 167 + } 168 + 169 + export default createGiteaIntegration;
+30
apps/api/src/gitea-integration/controllers/delete-gitea-integration.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import { HTTPException } from "hono/http-exception"; 3 + import db from "../../database"; 4 + import { integrationTable } from "../../database/schema"; 5 + 6 + async function deleteGiteaIntegration(projectId: string) { 7 + const integration = await db.query.integrationTable.findFirst({ 8 + where: and( 9 + eq(integrationTable.projectId, projectId), 10 + eq(integrationTable.type, "gitea"), 11 + ), 12 + }); 13 + 14 + if (!integration) { 15 + throw new HTTPException(404, { message: "Gitea integration not found" }); 16 + } 17 + 18 + await db 19 + .delete(integrationTable) 20 + .where( 21 + and( 22 + eq(integrationTable.projectId, projectId), 23 + eq(integrationTable.type, "gitea"), 24 + ), 25 + ); 26 + 27 + return { success: true, message: "Gitea integration deleted" }; 28 + } 29 + 30 + export default deleteGiteaIntegration;
+49
apps/api/src/gitea-integration/controllers/get-gitea-integration.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import db from "../../database"; 3 + import { integrationTable } from "../../database/schema"; 4 + import { 5 + defaultGiteaConfig, 6 + type GiteaConfig, 7 + } from "../../plugins/gitea/config"; 8 + 9 + function maskToken(token: string): string { 10 + if (token.length <= 8) { 11 + return "••••••••"; 12 + } 13 + return `${token.slice(0, 4)}••••••${token.slice(-4)}`; 14 + } 15 + 16 + async function getGiteaIntegration(projectId: string) { 17 + const integration = await db.query.integrationTable.findFirst({ 18 + where: and( 19 + eq(integrationTable.projectId, projectId), 20 + eq(integrationTable.type, "gitea"), 21 + ), 22 + }); 23 + 24 + if (!integration) { 25 + return null; 26 + } 27 + 28 + const config = JSON.parse(integration.config) as GiteaConfig; 29 + 30 + const apiBase = process.env.KANEO_API_URL || "http://localhost:1337"; 31 + 32 + return { 33 + id: integration.id, 34 + projectId: integration.projectId, 35 + baseUrl: config.baseUrl, 36 + repositoryOwner: config.repositoryOwner, 37 + repositoryName: config.repositoryName, 38 + maskedAccessToken: maskToken(config.accessToken), 39 + webhookUrl: `${apiBase.replace(/\/$/, "")}/api/gitea-integration/webhook/${integration.id}`, 40 + webhookSecret: config.webhookSecret ?? "", 41 + branchPattern: config.branchPattern || defaultGiteaConfig.branchPattern, 42 + commentTaskLinkOnGiteaIssue: config.commentTaskLinkOnGiteaIssue !== false, 43 + isActive: integration.isActive, 44 + createdAt: integration.createdAt, 45 + updatedAt: integration.updatedAt, 46 + }; 47 + } 48 + 49 + export default getGiteaIntegration;
+439
apps/api/src/gitea-integration/controllers/import-gitea-issues.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import { HTTPException } from "hono/http-exception"; 3 + import db from "../../database"; 4 + import { 5 + activityTable, 6 + integrationTable, 7 + labelTable, 8 + projectTable, 9 + taskTable, 10 + } from "../../database/schema"; 11 + import type { GiteaConfig } from "../../plugins/gitea/config"; 12 + import { extractTaskNumberGitea } from "../../plugins/gitea/utils/branch-matcher"; 13 + import { 14 + createGiteaClient, 15 + type GiteaIssue, 16 + } from "../../plugins/gitea/utils/gitea-api"; 17 + import { 18 + createExternalLink, 19 + findExternalLink, 20 + } from "../../plugins/github/services/link-manager"; 21 + import { findTaskByNumber } from "../../plugins/github/services/task-service"; 22 + import { 23 + extractIssuePriority, 24 + extractIssueStatus, 25 + } from "../../plugins/github/utils/extract-priority"; 26 + import { formatTaskDescriptionFromIssue } from "../../plugins/github/utils/format"; 27 + import getNextTaskNumber from "../../task/controllers/get-next-task-number"; 28 + 29 + type ImportResult = { 30 + imported: number; 31 + updated: number; 32 + skipped: number; 33 + errors?: string[]; 34 + }; 35 + 36 + export async function importGiteaIssues( 37 + projectId: string, 38 + ): Promise<ImportResult> { 39 + const errors: string[] = []; 40 + let imported = 0; 41 + let updated = 0; 42 + let skipped = 0; 43 + 44 + const project = await db.query.projectTable.findFirst({ 45 + where: eq(projectTable.id, projectId), 46 + }); 47 + 48 + if (!project) { 49 + throw new HTTPException(404, { message: "Project not found" }); 50 + } 51 + 52 + const integration = await db.query.integrationTable.findFirst({ 53 + where: and( 54 + eq(integrationTable.projectId, projectId), 55 + eq(integrationTable.type, "gitea"), 56 + ), 57 + }); 58 + 59 + if (!integration) { 60 + throw new HTTPException(404, { message: "Gitea integration not found" }); 61 + } 62 + 63 + if (!integration.isActive) { 64 + throw new HTTPException(400, { 65 + message: "Gitea integration is not active", 66 + }); 67 + } 68 + 69 + const config = JSON.parse(integration.config) as GiteaConfig; 70 + 71 + if (!config.accessToken || !config.baseUrl) { 72 + throw new HTTPException(400, { 73 + message: "Gitea access token or base URL not configured", 74 + }); 75 + } 76 + 77 + const client = createGiteaClient(config); 78 + 79 + const allIssues: GiteaIssue[] = []; 80 + let page = 1; 81 + 82 + while (true) { 83 + const issues = await client.listIssues( 84 + config.repositoryOwner, 85 + config.repositoryName, 86 + page, 87 + "open", 88 + ); 89 + 90 + if (issues.length === 0) break; 91 + 92 + const issuesOnly = issues.filter((issue) => !issue.pull_request); 93 + allIssues.push(...issuesOnly); 94 + 95 + if (issues.length < 100) break; 96 + page++; 97 + } 98 + 99 + for (const issue of allIssues) { 100 + try { 101 + const result = await importSingleIssue( 102 + issue, 103 + integration.id, 104 + projectId, 105 + project.workspaceId, 106 + config, 107 + client, 108 + ); 109 + 110 + if (result === "imported") { 111 + imported++; 112 + } else if (result === "updated") { 113 + updated++; 114 + } else { 115 + skipped++; 116 + } 117 + } catch (error) { 118 + const errorMessage = 119 + error instanceof Error ? error.message : String(error); 120 + errors.push(`Issue #${issue.number}: ${errorMessage}`); 121 + } 122 + } 123 + 124 + const allPRs: Array<{ 125 + number: number; 126 + title: string; 127 + body: string | null; 128 + html_url: string; 129 + state: string; 130 + head: { ref: string }; 131 + user?: { login?: string; username?: string; avatar_url?: string } | null; 132 + }> = []; 133 + page = 1; 134 + 135 + while (true) { 136 + const pulls = await client.listPulls( 137 + config.repositoryOwner, 138 + config.repositoryName, 139 + page, 140 + ); 141 + 142 + if (pulls.length === 0) break; 143 + 144 + allPRs.push(...pulls); 145 + 146 + if (pulls.length < 100) break; 147 + page++; 148 + } 149 + 150 + for (const pr of allPRs) { 151 + try { 152 + if (!pr.head?.ref) { 153 + continue; 154 + } 155 + await linkPullRequestToTask( 156 + { 157 + ...pr, 158 + head: { ref: pr.head.ref }, 159 + }, 160 + integration.id, 161 + projectId, 162 + project.slug, 163 + config, 164 + ); 165 + } catch (error) { 166 + const errorMessage = 167 + error instanceof Error ? error.message : String(error); 168 + errors.push(`PR #${pr.number}: ${errorMessage}`); 169 + } 170 + } 171 + 172 + return { 173 + imported, 174 + updated, 175 + skipped, 176 + ...(errors.length > 0 ? { errors } : {}), 177 + }; 178 + } 179 + 180 + async function importSingleIssue( 181 + issue: GiteaIssue, 182 + integrationId: string, 183 + projectId: string, 184 + workspaceId: string, 185 + config: GiteaConfig, 186 + client: ReturnType<typeof createGiteaClient>, 187 + ): Promise<"imported" | "updated" | "skipped"> { 188 + const existingLink = await findExternalLink( 189 + integrationId, 190 + "issue", 191 + issue.number.toString(), 192 + ); 193 + 194 + const labels = issue.labels ?? []; 195 + const priority = extractIssuePriority(labels as never); 196 + const status = extractIssueStatus(labels as never); 197 + 198 + if (existingLink) { 199 + const updateData: Record<string, unknown> = { 200 + title: issue.title, 201 + description: formatTaskDescriptionFromIssue(issue.body), 202 + }; 203 + 204 + if (priority) updateData.priority = priority; 205 + if (status) updateData.status = status; 206 + 207 + await db 208 + .update(taskTable) 209 + .set(updateData) 210 + .where(eq(taskTable.id, existingLink.taskId)); 211 + 212 + await importLabelsForTask(labels, existingLink.taskId, workspaceId); 213 + 214 + await importCommentsForTask( 215 + issue.number, 216 + existingLink.taskId, 217 + config, 218 + client, 219 + ); 220 + 221 + return "updated"; 222 + } 223 + 224 + const nextTaskNumber = await getNextTaskNumber(projectId); 225 + 226 + const taskValues: typeof taskTable.$inferInsert = { 227 + projectId, 228 + userId: null, 229 + title: issue.title, 230 + description: formatTaskDescriptionFromIssue(issue.body), 231 + status: status || "to-do", 232 + priority: priority || null, 233 + number: nextTaskNumber + 1, 234 + }; 235 + 236 + const [createdTask] = await db 237 + .insert(taskTable) 238 + .values(taskValues) 239 + .returning(); 240 + 241 + if (!createdTask) { 242 + throw new Error("Failed to create task"); 243 + } 244 + 245 + await createExternalLink({ 246 + taskId: createdTask.id, 247 + integrationId, 248 + resourceType: "issue", 249 + externalId: issue.number.toString(), 250 + url: issue.html_url, 251 + title: issue.title, 252 + metadata: { 253 + state: issue.state, 254 + createdFrom: "gitea-import", 255 + author: issue.user?.login ?? issue.user?.username, 256 + }, 257 + }); 258 + 259 + await importLabelsForTask(labels, createdTask.id, workspaceId); 260 + 261 + await importCommentsForTask(issue.number, createdTask.id, config, client); 262 + 263 + return "imported"; 264 + } 265 + 266 + async function importLabelsForTask( 267 + issueLabels: GiteaIssue["labels"], 268 + taskId: string, 269 + workspaceId: string, 270 + ): Promise<void> { 271 + const nonSystemLabels = (issueLabels ?? []) 272 + .map((label) => { 273 + if (typeof label === "string") { 274 + return { name: label, color: "#6B7280" }; 275 + } 276 + return { 277 + name: label.name, 278 + color: label.color 279 + ? `#${String(label.color).replace(/^#/, "")}` 280 + : "#6B7280", 281 + }; 282 + }) 283 + .filter( 284 + (label) => 285 + label.name && 286 + !label.name.startsWith("priority:") && 287 + !label.name.startsWith("status:"), 288 + ) as Array<{ name: string; color: string }>; 289 + 290 + for (const labelData of nonSystemLabels) { 291 + const existingLabelOnTask = await db.query.labelTable.findFirst({ 292 + where: and( 293 + eq(labelTable.taskId, taskId), 294 + eq(labelTable.name, labelData.name), 295 + ), 296 + }); 297 + 298 + if (existingLabelOnTask) { 299 + continue; 300 + } 301 + 302 + const existingWorkspaceLabel = await db.query.labelTable.findFirst({ 303 + where: and( 304 + eq(labelTable.workspaceId, workspaceId), 305 + eq(labelTable.name, labelData.name), 306 + ), 307 + }); 308 + 309 + const colorToUse = existingWorkspaceLabel?.color || labelData.color; 310 + 311 + await db.insert(labelTable).values({ 312 + name: labelData.name, 313 + color: colorToUse, 314 + taskId, 315 + workspaceId, 316 + }); 317 + } 318 + } 319 + 320 + async function importCommentsForTask( 321 + issueNumber: number, 322 + taskId: string, 323 + config: GiteaConfig, 324 + client: ReturnType<typeof createGiteaClient>, 325 + ): Promise<void> { 326 + const allComments: Array<{ 327 + body: string; 328 + html_url: string; 329 + user?: { login?: string; username?: string; avatar_url?: string } | null; 330 + }> = []; 331 + let page = 1; 332 + 333 + while (true) { 334 + const comments = await client.listIssueComments( 335 + config.repositoryOwner, 336 + config.repositoryName, 337 + issueNumber, 338 + page, 339 + 100, 340 + ); 341 + 342 + if (comments.length === 0) break; 343 + 344 + allComments.push(...comments); 345 + 346 + if (comments.length < 100) break; 347 + page++; 348 + } 349 + 350 + const existingActivities = await db.query.activityTable.findMany({ 351 + where: and( 352 + eq(activityTable.taskId, taskId), 353 + eq(activityTable.externalSource, "gitea"), 354 + ), 355 + }); 356 + 357 + const existingExternalUrls = new Set( 358 + existingActivities.filter((a) => a.externalUrl).map((a) => a.externalUrl), 359 + ); 360 + 361 + for (const comment of allComments) { 362 + const username = comment.user?.login ?? comment.user?.username ?? ""; 363 + if (username.endsWith("[bot]")) { 364 + continue; 365 + } 366 + 367 + if (existingExternalUrls.has(comment.html_url)) { 368 + continue; 369 + } 370 + 371 + await db.insert(activityTable).values({ 372 + taskId, 373 + type: "comment", 374 + content: comment.body, 375 + externalUserName: username || "Unknown", 376 + externalUserAvatar: comment.user?.avatar_url ?? null, 377 + externalSource: "gitea", 378 + externalUrl: comment.html_url, 379 + }); 380 + } 381 + } 382 + 383 + async function linkPullRequestToTask( 384 + pr: { 385 + number: number; 386 + title: string; 387 + body: string | null; 388 + html_url: string; 389 + state: string; 390 + head: { ref: string }; 391 + user?: { login?: string; username?: string; avatar_url?: string } | null; 392 + }, 393 + integrationId: string, 394 + projectId: string, 395 + projectSlug: string, 396 + config: GiteaConfig, 397 + ): Promise<void> { 398 + const taskNumber = extractTaskNumberGitea( 399 + pr.head.ref, 400 + pr.title, 401 + pr.body ?? undefined, 402 + config, 403 + projectSlug, 404 + ); 405 + 406 + if (!taskNumber) { 407 + return; 408 + } 409 + 410 + const task = await findTaskByNumber(projectId, taskNumber); 411 + 412 + if (!task) { 413 + return; 414 + } 415 + 416 + const existingLink = await findExternalLink( 417 + integrationId, 418 + "pull_request", 419 + pr.number.toString(), 420 + ); 421 + 422 + if (existingLink) { 423 + return; 424 + } 425 + 426 + await createExternalLink({ 427 + taskId: task.id, 428 + integrationId, 429 + resourceType: "pull_request", 430 + externalId: pr.number.toString(), 431 + url: pr.html_url, 432 + title: pr.title, 433 + metadata: { 434 + state: pr.state, 435 + branch: pr.head.ref, 436 + author: pr.user?.login ?? pr.user?.username, 437 + }, 438 + }); 439 + }
+66
apps/api/src/gitea-integration/controllers/list-gitea-repositories.ts
··· 1 + import { HTTPException } from "hono/http-exception"; 2 + import { normalizeGiteaBaseUrl } from "../../plugins/gitea/config"; 3 + import { 4 + createGiteaClient, 5 + verifyGiteaToken, 6 + } from "../../plugins/gitea/utils/gitea-api"; 7 + 8 + type RepoRow = { 9 + id: number; 10 + name: string; 11 + full_name: string; 12 + private: boolean; 13 + owner: { login: string }; 14 + html_url: string; 15 + }; 16 + 17 + async function listGiteaRepositories({ 18 + baseUrl, 19 + accessToken, 20 + }: { 21 + baseUrl: string; 22 + accessToken: string; 23 + }): Promise<{ repositories: RepoRow[] }> { 24 + const normalized = normalizeGiteaBaseUrl(baseUrl); 25 + 26 + try { 27 + await verifyGiteaToken(normalized, accessToken); 28 + } catch { 29 + throw new HTTPException(401, { 30 + message: "Invalid Gitea token or could not reach instance.", 31 + }); 32 + } 33 + 34 + const client = createGiteaClient({ 35 + baseUrl: normalized, 36 + accessToken, 37 + }); 38 + 39 + const all: RepoRow[] = []; 40 + let page = 1; 41 + 42 + while (true) { 43 + const batch = await client.listUserRepos(page, 50); 44 + if (!batch.length) break; 45 + 46 + for (const repo of batch) { 47 + const ownerLogin = repo.owner?.login ?? repo.owner?.username ?? ""; 48 + all.push({ 49 + id: repo.id, 50 + name: repo.name, 51 + full_name: repo.full_name, 52 + private: repo.private, 53 + owner: { login: ownerLogin }, 54 + html_url: repo.html_url, 55 + }); 56 + } 57 + 58 + if (batch.length < 50) break; 59 + page += 1; 60 + if (page > 50) break; 61 + } 62 + 63 + return { repositories: all }; 64 + } 65 + 66 + export default listGiteaRepositories;
+75
apps/api/src/gitea-integration/controllers/verify-gitea-access.ts
··· 1 + import { HTTPException } from "hono/http-exception"; 2 + import { normalizeGiteaBaseUrl } from "../../plugins/gitea/config"; 3 + import { 4 + createGiteaClient, 5 + verifyGiteaToken, 6 + } from "../../plugins/gitea/utils/gitea-api"; 7 + 8 + async function verifyGiteaAccess({ 9 + baseUrl, 10 + accessToken, 11 + repositoryOwner, 12 + repositoryName, 13 + }: { 14 + baseUrl: string; 15 + accessToken: string; 16 + repositoryOwner: string; 17 + repositoryName: string; 18 + }) { 19 + try { 20 + const normalized = normalizeGiteaBaseUrl(baseUrl); 21 + await verifyGiteaToken(normalized, accessToken); 22 + 23 + const client = createGiteaClient({ 24 + baseUrl: normalized, 25 + accessToken, 26 + }); 27 + 28 + const repo = await client.getRepo(repositoryOwner, repositoryName); 29 + 30 + const perms = repo.permissions; 31 + const hasIssuesWrite = 32 + perms?.admin === true || 33 + perms?.push === true || 34 + (perms?.pull === true && perms?.push !== false); 35 + 36 + return { 37 + isInstalled: true, 38 + hasRequiredPermissions: Boolean(hasIssuesWrite), 39 + repositoryExists: true, 40 + repositoryPrivate: repo.private, 41 + missingPermissions: hasIssuesWrite ? [] : ["issues (write)"], 42 + message: hasIssuesWrite 43 + ? "Token can access the repository." 44 + : "Token may not have sufficient permissions to manage issues.", 45 + }; 46 + } catch (error) { 47 + const err = error as { status?: number; message?: string }; 48 + 49 + if (err.status === 404) { 50 + return { 51 + isInstalled: false, 52 + hasRequiredPermissions: false, 53 + repositoryExists: false, 54 + repositoryPrivate: null, 55 + missingPermissions: [] as string[], 56 + message: "Repository not found or not accessible with this token.", 57 + }; 58 + } 59 + 60 + if (err.status === 401) { 61 + throw new HTTPException(401, { 62 + message: "Invalid Gitea token or unauthorized.", 63 + }); 64 + } 65 + 66 + throw new HTTPException(500, { 67 + message: 68 + error instanceof Error 69 + ? error.message 70 + : "Failed to verify Gitea access", 71 + }); 72 + } 73 + } 74 + 75 + export default verifyGiteaAccess;
+401
apps/api/src/gitea-integration/index.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import type { Context } from "hono"; 3 + import { Hono } from "hono"; 4 + import { HTTPException } from "hono/http-exception"; 5 + import { describeRoute, resolver, validator } from "hono-openapi"; 6 + import * as v from "valibot"; 7 + import db from "../database"; 8 + import { integrationTable, projectTable } from "../database/schema"; 9 + import { type GiteaConfig, validateGiteaConfig } from "../plugins/gitea/config"; 10 + import { handleGiteaWebhookRequest } from "../plugins/gitea/webhook-handler"; 11 + import { giteaIntegrationSchema } from "../schemas"; 12 + import { validateWorkspaceAccess } from "../utils/validate-workspace-access"; 13 + import { workspaceAccess } from "../utils/workspace-access-middleware"; 14 + import createGiteaIntegration from "./controllers/create-gitea-integration"; 15 + import deleteGiteaIntegration from "./controllers/delete-gitea-integration"; 16 + import getGiteaIntegration from "./controllers/get-gitea-integration"; 17 + import { importGiteaIssues } from "./controllers/import-gitea-issues"; 18 + import listGiteaRepositories from "./controllers/list-gitea-repositories"; 19 + import verifyGiteaAccess from "./controllers/verify-gitea-access"; 20 + 21 + const 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 + 32 + const 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 + 41 + const 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 + 48 + const giteaIntegration = new Hono<{ 49 + Variables: { 50 + userId: string; 51 + workspaceId: string; 52 + apiKey?: { 53 + id: string; 54 + userId: string; 55 + enabled: boolean; 56 + }; 57 + }; 58 + }>() 59 + .post( 60 + "/repositories", 61 + describeRoute({ 62 + operationId: "listGiteaRepositories", 63 + tags: ["Gitea"], 64 + description: "List repositories accessible with a Gitea token", 65 + responses: { 66 + 200: { 67 + description: "Repositories", 68 + content: { 69 + "application/json": { 70 + schema: resolver( 71 + v.object({ 72 + repositories: v.array(giteaRepositorySchema), 73 + }), 74 + ), 75 + }, 76 + }, 77 + }, 78 + }, 79 + }), 80 + validator( 81 + "json", 82 + v.object({ 83 + baseUrl: v.pipe(v.string(), v.minLength(1)), 84 + accessToken: v.pipe(v.string(), v.minLength(1)), 85 + }), 86 + ), 87 + async (c) => { 88 + const { baseUrl, accessToken } = c.req.valid("json"); 89 + const result = await listGiteaRepositories({ baseUrl, accessToken }); 90 + return c.json(result); 91 + }, 92 + ) 93 + .post( 94 + "/verify", 95 + describeRoute({ 96 + operationId: "verifyGiteaAccess", 97 + tags: ["Gitea"], 98 + description: "Verify Gitea token and repository access", 99 + responses: { 100 + 200: { 101 + description: "Verification result", 102 + content: { 103 + "application/json": { 104 + schema: resolver(verificationResultSchema), 105 + }, 106 + }, 107 + }, 108 + }, 109 + }), 110 + validator( 111 + "json", 112 + v.object({ 113 + baseUrl: v.pipe(v.string(), v.minLength(1)), 114 + accessToken: v.pipe(v.string(), v.minLength(1)), 115 + repositoryOwner: v.pipe(v.string(), v.minLength(1)), 116 + repositoryName: v.pipe(v.string(), v.minLength(1)), 117 + }), 118 + ), 119 + async (c) => { 120 + const body = c.req.valid("json"); 121 + const result = await verifyGiteaAccess(body); 122 + return c.json(result); 123 + }, 124 + ) 125 + .get( 126 + "/project/:projectId", 127 + describeRoute({ 128 + operationId: "getGiteaIntegration", 129 + tags: ["Gitea"], 130 + description: "Get Gitea integration for a project", 131 + responses: { 132 + 200: { 133 + description: "Gitea integration details", 134 + content: { 135 + "application/json": { 136 + schema: resolver(giteaIntegrationSchema), 137 + }, 138 + }, 139 + }, 140 + }, 141 + }), 142 + validator("param", v.object({ projectId: v.string() })), 143 + workspaceAccess.fromProject("projectId"), 144 + async (c) => { 145 + const { projectId } = c.req.valid("param"); 146 + const integration = await getGiteaIntegration(projectId); 147 + if (!integration) { 148 + return c.json(null, 200); 149 + } 150 + return c.json(integration); 151 + }, 152 + ) 153 + .post( 154 + "/project/:projectId", 155 + describeRoute({ 156 + operationId: "createGiteaIntegration", 157 + tags: ["Gitea"], 158 + description: "Create or update Gitea integration for a project", 159 + responses: { 160 + 200: { 161 + description: "Integration saved", 162 + content: { 163 + "application/json": { 164 + schema: resolver(giteaIntegrationSchema), 165 + }, 166 + }, 167 + }, 168 + }, 169 + }), 170 + validator("param", v.object({ projectId: v.string() })), 171 + validator( 172 + "json", 173 + v.object({ 174 + baseUrl: v.pipe(v.string(), v.minLength(1)), 175 + accessToken: v.optional(v.string()), 176 + repositoryOwner: v.pipe(v.string(), v.minLength(1)), 177 + repositoryName: v.pipe(v.string(), v.minLength(1)), 178 + }), 179 + ), 180 + workspaceAccess.fromProject("projectId"), 181 + async (c) => { 182 + const { projectId } = c.req.valid("param"); 183 + const body = c.req.valid("json"); 184 + await createGiteaIntegration({ 185 + projectId, 186 + baseUrl: body.baseUrl, 187 + accessToken: body.accessToken, 188 + repositoryOwner: body.repositoryOwner, 189 + repositoryName: body.repositoryName, 190 + }); 191 + const integration = await getGiteaIntegration(projectId); 192 + if (!integration) { 193 + throw new HTTPException(500, { message: "Failed to load integration" }); 194 + } 195 + return c.json(integration); 196 + }, 197 + ) 198 + .patch( 199 + "/project/:projectId", 200 + describeRoute({ 201 + operationId: "updateGiteaIntegration", 202 + tags: ["Gitea"], 203 + description: "Update Gitea integration settings", 204 + responses: { 205 + 200: { 206 + description: "Updated", 207 + content: { 208 + "application/json": { 209 + schema: resolver(giteaIntegrationSchema), 210 + }, 211 + }, 212 + }, 213 + }, 214 + }), 215 + validator("param", v.object({ projectId: v.string() })), 216 + validator( 217 + "json", 218 + v.object({ 219 + isActive: v.optional(v.boolean()), 220 + commentTaskLinkOnGiteaIssue: v.optional(v.boolean()), 221 + }), 222 + ), 223 + workspaceAccess.fromProject("projectId"), 224 + async (c) => { 225 + const { projectId } = c.req.valid("param"); 226 + const body = c.req.valid("json"); 227 + 228 + const row = await db.query.integrationTable.findFirst({ 229 + where: and( 230 + eq(integrationTable.projectId, projectId), 231 + eq(integrationTable.type, "gitea"), 232 + ), 233 + }); 234 + 235 + if (!row) { 236 + return c.json({ error: "Integration not found" }, 404); 237 + } 238 + 239 + let config: GiteaConfig; 240 + try { 241 + config = JSON.parse(row.config) as GiteaConfig; 242 + } catch { 243 + throw new HTTPException(500, { message: "Invalid integration config" }); 244 + } 245 + 246 + if (body.commentTaskLinkOnGiteaIssue !== undefined) { 247 + config = { 248 + ...config, 249 + commentTaskLinkOnGiteaIssue: body.commentTaskLinkOnGiteaIssue, 250 + }; 251 + } 252 + 253 + const validation = await validateGiteaConfig(config); 254 + if (!validation.valid) { 255 + throw new HTTPException(400, { 256 + message: validation.errors?.join(", ") ?? "Invalid config", 257 + }); 258 + } 259 + 260 + await db 261 + .update(integrationTable) 262 + .set({ 263 + config: JSON.stringify(config), 264 + isActive: 265 + body.isActive !== undefined 266 + ? body.isActive 267 + : (row.isActive ?? true), 268 + updatedAt: new Date(), 269 + }) 270 + .where( 271 + and( 272 + eq(integrationTable.projectId, projectId), 273 + eq(integrationTable.type, "gitea"), 274 + ), 275 + ); 276 + 277 + const updated = await getGiteaIntegration(projectId); 278 + if (!updated) { 279 + throw new HTTPException(500, { message: "Failed to load integration" }); 280 + } 281 + return c.json(updated, 200); 282 + }, 283 + ) 284 + .delete( 285 + "/project/:projectId", 286 + describeRoute({ 287 + operationId: "deleteGiteaIntegration", 288 + tags: ["Gitea"], 289 + description: "Delete Gitea integration for a project", 290 + responses: { 291 + 200: { 292 + description: "Deleted", 293 + content: { 294 + "application/json": { 295 + schema: resolver( 296 + v.object({ 297 + success: v.boolean(), 298 + message: v.string(), 299 + }), 300 + ), 301 + }, 302 + }, 303 + }, 304 + }, 305 + }), 306 + validator("param", v.object({ projectId: v.string() })), 307 + workspaceAccess.fromProject("projectId"), 308 + async (c) => { 309 + const { projectId } = c.req.valid("param"); 310 + const result = await deleteGiteaIntegration(projectId); 311 + return c.json(result); 312 + }, 313 + ) 314 + .post( 315 + "/import-issues", 316 + describeRoute({ 317 + operationId: "importGiteaIssues", 318 + tags: ["Gitea"], 319 + description: "Import Gitea issues as tasks", 320 + responses: { 321 + 200: { 322 + description: "Import result", 323 + content: { 324 + "application/json": { 325 + schema: resolver(importResultSchema), 326 + }, 327 + }, 328 + }, 329 + }, 330 + }), 331 + validator( 332 + "json", 333 + v.object({ 334 + projectId: v.string(), 335 + }), 336 + ), 337 + async (c, next) => { 338 + const userId = c.get("userId"); 339 + if (!userId) { 340 + throw new HTTPException(401, { message: "Unauthorized" }); 341 + } 342 + 343 + const { projectId } = c.req.valid("json"); 344 + 345 + const [project] = await db 346 + .select({ workspaceId: projectTable.workspaceId }) 347 + .from(projectTable) 348 + .where(eq(projectTable.id, projectId)) 349 + .limit(1); 350 + 351 + if (!project) { 352 + throw new HTTPException(404, { message: "Project not found" }); 353 + } 354 + 355 + const apiKey = c.get("apiKey"); 356 + const apiKeyId = apiKey?.id; 357 + 358 + await validateWorkspaceAccess(userId, project.workspaceId, apiKeyId); 359 + c.set("workspaceId", project.workspaceId); 360 + 361 + return next(); 362 + }, 363 + async (c) => { 364 + const { projectId } = c.req.valid("json"); 365 + const result = await importGiteaIssues(projectId); 366 + return c.json(result); 367 + }, 368 + ); 369 + 370 + export async function handleGiteaWebhookRoute(c: Context) { 371 + const integrationId = c.req.param("integrationId"); 372 + if (!integrationId) { 373 + return c.json({ error: "Missing integration id" }, 400); 374 + } 375 + 376 + const arrayBuffer = await c.req.arrayBuffer(); 377 + const body = Buffer.from(arrayBuffer).toString("utf8"); 378 + 379 + const signature = 380 + c.req.header("x-gitea-signature") || c.req.header("X-Gitea-Signature"); 381 + 382 + const eventName = 383 + c.req.header("x-gitea-event") || 384 + c.req.header("X-Gitea-Event") || 385 + c.req.header("x-github-event"); 386 + 387 + const result = await handleGiteaWebhookRequest( 388 + integrationId, 389 + body, 390 + signature, 391 + eventName, 392 + ); 393 + 394 + if (!result.success) { 395 + return c.json({ error: result.error }, 400); 396 + } 397 + 398 + return c.json({ status: "success" }); 399 + } 400 + 401 + export default giteaIntegration;