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 plugin with webhooks and sync helpers

Tin 77e7836f 9e5a6a1d

+2514
+75
apps/api/src/plugins/gitea/config.ts
··· 1 + import * as v from "valibot"; 2 + import { branchPatterns } from "../github/config"; 3 + 4 + export { branchPatterns }; 5 + 6 + export const giteaConfigSchema = v.object({ 7 + baseUrl: v.string(), 8 + accessToken: v.string(), 9 + repositoryOwner: v.string(), 10 + repositoryName: v.string(), 11 + webhookSecret: v.optional(v.string()), 12 + branchPattern: v.optional(v.string()), 13 + customBranchRegex: v.optional(v.string()), 14 + commentTaskLinkOnGiteaIssue: v.optional(v.boolean()), 15 + statusTransitions: v.optional( 16 + v.object({ 17 + onBranchPush: v.optional(v.string()), 18 + onPROpen: v.optional(v.string()), 19 + onPRMerge: v.optional(v.string()), 20 + }), 21 + ), 22 + }); 23 + 24 + export type GiteaConfig = v.InferOutput<typeof giteaConfigSchema>; 25 + 26 + export async function validateGiteaConfig( 27 + config: unknown, 28 + ): Promise<{ valid: boolean; errors?: string[] }> { 29 + try { 30 + v.parse(giteaConfigSchema, config); 31 + return { valid: true }; 32 + } catch (error) { 33 + if (error instanceof v.ValiError) { 34 + return { 35 + valid: false, 36 + errors: error.issues.map((issue) => issue.message), 37 + }; 38 + } 39 + return { 40 + valid: false, 41 + errors: [error instanceof Error ? error.message : "Invalid config"], 42 + }; 43 + } 44 + } 45 + 46 + export const defaultGiteaConfig: Partial<GiteaConfig> = { 47 + branchPattern: "{slug}-{number}", 48 + commentTaskLinkOnGiteaIssue: true, 49 + statusTransitions: { 50 + onBranchPush: "in-progress", 51 + onPROpen: "in-review", 52 + onPRMerge: "done", 53 + }, 54 + }; 55 + 56 + export function normalizeGiteaBaseUrl(url: string): string { 57 + return url.trim().replace(/\/+$/, ""); 58 + } 59 + 60 + export function getDefaultGiteaConfig( 61 + baseUrl: string, 62 + accessToken: string, 63 + repositoryOwner: string, 64 + repositoryName: string, 65 + webhookSecret: string, 66 + ): GiteaConfig { 67 + return { 68 + baseUrl: normalizeGiteaBaseUrl(baseUrl), 69 + accessToken, 70 + repositoryOwner, 71 + repositoryName, 72 + webhookSecret, 73 + ...defaultGiteaConfig, 74 + }; 75 + }
+40
apps/api/src/plugins/gitea/events/task-comment-created.ts
··· 1 + import { findExternalLinkByTaskAndType } from "../../github/services/link-manager"; 2 + import type { PluginContext, TaskCommentCreatedEvent } from "../../types"; 3 + import type { GiteaConfig } from "../config"; 4 + import { createGiteaClient } from "../utils/gitea-api"; 5 + 6 + export async function handleTaskCommentCreated( 7 + event: TaskCommentCreatedEvent, 8 + context: PluginContext, 9 + ): Promise<void> { 10 + const config = context.config as GiteaConfig; 11 + if (!config.baseUrl || !config.accessToken) { 12 + return; 13 + } 14 + 15 + const { repositoryOwner, repositoryName } = config; 16 + 17 + const existingLink = await findExternalLinkByTaskAndType( 18 + event.taskId, 19 + context.integrationId, 20 + "issue", 21 + ); 22 + 23 + if (!existingLink) { 24 + return; 25 + } 26 + 27 + try { 28 + const client = createGiteaClient(config); 29 + const issueNumber = Number.parseInt(existingLink.externalId, 10); 30 + 31 + await client.createIssueComment( 32 + repositoryOwner, 33 + repositoryName, 34 + issueNumber, 35 + event.comment, 36 + ); 37 + } catch (error) { 38 + console.error("Failed to create Gitea comment:", error); 39 + } 40 + }
+65
apps/api/src/plugins/gitea/events/task-created.ts
··· 1 + import { 2 + createExternalLink, 3 + findExternalLinkByTaskAndType, 4 + } from "../../github/services/link-manager"; 5 + import { 6 + formatIssueBody, 7 + formatIssueTitle, 8 + getLabelsForIssue, 9 + } from "../../github/utils/format"; 10 + import type { PluginContext, TaskCreatedEvent } from "../../types"; 11 + import type { GiteaConfig } from "../config"; 12 + import { createGiteaClient } from "../utils/gitea-api"; 13 + import { addLabelsToIssueGitea } from "../utils/labels"; 14 + 15 + export async function handleTaskCreated( 16 + event: TaskCreatedEvent, 17 + context: PluginContext, 18 + ): Promise<void> { 19 + const config = context.config as GiteaConfig; 20 + if (!config.baseUrl || !config.accessToken) { 21 + return; 22 + } 23 + 24 + const { repositoryOwner, repositoryName } = config; 25 + 26 + const existingLink = await findExternalLinkByTaskAndType( 27 + event.taskId, 28 + context.integrationId, 29 + "issue", 30 + ); 31 + 32 + if (existingLink) { 33 + return; 34 + } 35 + 36 + try { 37 + const client = createGiteaClient(config); 38 + const createdIssue = await client.createIssue( 39 + repositoryOwner, 40 + repositoryName, 41 + { 42 + title: formatIssueTitle(event.title), 43 + body: formatIssueBody(event.description, event.taskId), 44 + }, 45 + ); 46 + 47 + const labels = getLabelsForIssue(event.priority, event.status); 48 + await addLabelsToIssueGitea(config, createdIssue.number, labels); 49 + 50 + await createExternalLink({ 51 + taskId: event.taskId, 52 + integrationId: context.integrationId, 53 + resourceType: "issue", 54 + externalId: createdIssue.number.toString(), 55 + url: createdIssue.html_url, 56 + title: createdIssue.title, 57 + metadata: { 58 + state: createdIssue.state, 59 + createdFrom: "kaneo", 60 + }, 61 + }); 62 + } catch (error) { 63 + console.error("Failed to create Gitea issue:", error); 64 + } 65 + }
+84
apps/api/src/plugins/gitea/events/task-description-changed.ts
··· 1 + import { 2 + findExternalLinksByTask, 3 + updateExternalLink, 4 + } from "../../github/services/link-manager"; 5 + import { formatIssueBody } from "../../github/utils/format"; 6 + import type { PluginContext, TaskDescriptionChangedEvent } from "../../types"; 7 + import type { GiteaConfig } from "../config"; 8 + import { createGiteaClient } from "../utils/gitea-api"; 9 + 10 + export async function handleTaskDescriptionChanged( 11 + event: TaskDescriptionChangedEvent, 12 + context: PluginContext, 13 + ): Promise<void> { 14 + const config = context.config as GiteaConfig; 15 + if (!config.baseUrl || !config.accessToken) { 16 + return; 17 + } 18 + 19 + const { repositoryOwner, repositoryName } = config; 20 + 21 + try { 22 + const links = await findExternalLinksByTask(event.taskId); 23 + const issueLink = links.find( 24 + (link) => 25 + link.integrationId === context.integrationId && 26 + link.resourceType === "issue", 27 + ); 28 + 29 + if (!issueLink) { 30 + return; 31 + } 32 + 33 + const metadata = issueLink.metadata ? JSON.parse(issueLink.metadata) : {}; 34 + 35 + const lastDescSync = metadata.lastSync?.description; 36 + const newDescNormalized = event.newDescription || ""; 37 + 38 + if (lastDescSync) { 39 + if ( 40 + lastDescSync.value === newDescNormalized && 41 + lastDescSync.source === "gitea" 42 + ) { 43 + console.log("Skipping description sync - already synced from Gitea"); 44 + return; 45 + } 46 + 47 + const timeSinceLastSync = 48 + Date.now() - new Date(lastDescSync.timestamp).getTime(); 49 + if (timeSinceLastSync < 2000) { 50 + console.log( 51 + `Skipping description sync - recent sync detected (${timeSinceLastSync}ms ago)`, 52 + ); 53 + return; 54 + } 55 + } 56 + 57 + const client = createGiteaClient(config); 58 + const issueNumber = Number.parseInt(issueLink.externalId, 10); 59 + 60 + const formattedBody = formatIssueBody(event.newDescription, event.taskId); 61 + 62 + await client.updateIssue(repositoryOwner, repositoryName, issueNumber, { 63 + body: formattedBody, 64 + }); 65 + 66 + await updateExternalLink(issueLink.id, { 67 + metadata: { 68 + ...metadata, 69 + lastSync: { 70 + ...metadata.lastSync, 71 + description: { 72 + timestamp: new Date().toISOString(), 73 + source: "kaneo", 74 + value: newDescNormalized, 75 + }, 76 + }, 77 + }, 78 + }); 79 + 80 + console.log(`Synced task description to Gitea issue #${issueNumber}`); 81 + } catch (error) { 82 + console.error("Failed to update Gitea issue description:", error); 83 + } 84 + }
+45
apps/api/src/plugins/gitea/events/task-priority-changed.ts
··· 1 + import { findExternalLinksByTask } from "../../github/services/link-manager"; 2 + import type { PluginContext, TaskPriorityChangedEvent } from "../../types"; 3 + import type { GiteaConfig } from "../config"; 4 + import { addLabelsToIssueGitea, removeLabelGitea } from "../utils/labels"; 5 + 6 + export async function handleTaskPriorityChanged( 7 + event: TaskPriorityChangedEvent, 8 + context: PluginContext, 9 + ): Promise<void> { 10 + const config = context.config as GiteaConfig; 11 + if (!config.baseUrl || !config.accessToken) { 12 + return; 13 + } 14 + 15 + try { 16 + const links = await findExternalLinksByTask(event.taskId); 17 + const issueLink = links.find( 18 + (link) => 19 + link.integrationId === context.integrationId && 20 + link.resourceType === "issue", 21 + ); 22 + 23 + if (!issueLink) { 24 + return; 25 + } 26 + 27 + const issueNumber = Number.parseInt(issueLink.externalId, 10); 28 + 29 + if (event.oldPriority && event.oldPriority !== "no-priority") { 30 + await removeLabelGitea( 31 + config, 32 + issueNumber, 33 + `priority:${event.oldPriority}`, 34 + ); 35 + } 36 + 37 + if (event.newPriority && event.newPriority !== "no-priority") { 38 + await addLabelsToIssueGitea(config, issueNumber, [ 39 + `priority:${event.newPriority}`, 40 + ]); 41 + } 42 + } catch (error) { 43 + console.error("Failed to update Gitea issue priority:", error); 44 + } 45 + }
+68
apps/api/src/plugins/gitea/events/task-status-changed.ts
··· 1 + import { 2 + findExternalLinksByTask, 3 + updateExternalLink, 4 + } from "../../github/services/link-manager"; 5 + import type { PluginContext, TaskStatusChangedEvent } from "../../types"; 6 + import type { GiteaConfig } from "../config"; 7 + import { createGiteaClient } from "../utils/gitea-api"; 8 + import { addLabelsToIssueGitea, removeLabelGitea } from "../utils/labels"; 9 + 10 + export async function handleTaskStatusChanged( 11 + event: TaskStatusChangedEvent, 12 + context: PluginContext, 13 + ): Promise<void> { 14 + const config = context.config as GiteaConfig; 15 + if (!config.baseUrl || !config.accessToken) { 16 + return; 17 + } 18 + 19 + const { repositoryOwner, repositoryName } = config; 20 + 21 + try { 22 + const links = await findExternalLinksByTask(event.taskId); 23 + const issueLink = links.find( 24 + (link) => 25 + link.integrationId === context.integrationId && 26 + link.resourceType === "issue", 27 + ); 28 + 29 + if (!issueLink) { 30 + return; 31 + } 32 + 33 + const client = createGiteaClient(config); 34 + const issueNumber = Number.parseInt(issueLink.externalId, 10); 35 + 36 + await removeLabelGitea(config, issueNumber, `status:${event.oldStatus}`); 37 + 38 + await addLabelsToIssueGitea(config, issueNumber, [ 39 + `status:${event.newStatus}`, 40 + ]); 41 + 42 + if (event.newStatus === "done") { 43 + await client.updateIssue(repositoryOwner, repositoryName, issueNumber, { 44 + state: "closed", 45 + }); 46 + 47 + await updateExternalLink(issueLink.id, { 48 + metadata: { 49 + ...(issueLink.metadata ? JSON.parse(issueLink.metadata) : {}), 50 + state: "closed", 51 + }, 52 + }); 53 + } else if (event.oldStatus === "done" && event.newStatus !== "done") { 54 + await client.updateIssue(repositoryOwner, repositoryName, issueNumber, { 55 + state: "open", 56 + }); 57 + 58 + await updateExternalLink(issueLink.id, { 59 + metadata: { 60 + ...(issueLink.metadata ? JSON.parse(issueLink.metadata) : {}), 61 + state: "open", 62 + }, 63 + }); 64 + } 65 + } catch (error) { 66 + console.error("Failed to update Gitea issue status:", error); 67 + } 68 + }
+80
apps/api/src/plugins/gitea/events/task-title-changed.ts
··· 1 + import { 2 + findExternalLinksByTask, 3 + updateExternalLink, 4 + } from "../../github/services/link-manager"; 5 + import type { PluginContext, TaskTitleChangedEvent } from "../../types"; 6 + import type { GiteaConfig } from "../config"; 7 + import { createGiteaClient } from "../utils/gitea-api"; 8 + 9 + export async function handleTaskTitleChanged( 10 + event: TaskTitleChangedEvent, 11 + context: PluginContext, 12 + ): Promise<void> { 13 + const config = context.config as GiteaConfig; 14 + if (!config.baseUrl || !config.accessToken) { 15 + return; 16 + } 17 + 18 + const { repositoryOwner, repositoryName } = config; 19 + 20 + try { 21 + const links = await findExternalLinksByTask(event.taskId); 22 + const issueLink = links.find( 23 + (link) => 24 + link.integrationId === context.integrationId && 25 + link.resourceType === "issue", 26 + ); 27 + 28 + if (!issueLink) { 29 + return; 30 + } 31 + 32 + const metadata = issueLink.metadata ? JSON.parse(issueLink.metadata) : {}; 33 + 34 + const lastTitleSync = metadata.lastSync?.title; 35 + if (lastTitleSync) { 36 + if ( 37 + lastTitleSync.value === event.newTitle && 38 + lastTitleSync.source === "gitea" 39 + ) { 40 + console.log("Skipping title sync - already synced from Gitea"); 41 + return; 42 + } 43 + 44 + const timeSinceLastSync = 45 + Date.now() - new Date(lastTitleSync.timestamp).getTime(); 46 + if (timeSinceLastSync < 2000) { 47 + console.log( 48 + `Skipping title sync - recent sync detected (${timeSinceLastSync}ms ago)`, 49 + ); 50 + return; 51 + } 52 + } 53 + 54 + const client = createGiteaClient(config); 55 + const issueNumber = Number.parseInt(issueLink.externalId, 10); 56 + 57 + await client.updateIssue(repositoryOwner, repositoryName, issueNumber, { 58 + title: event.newTitle, 59 + }); 60 + 61 + await updateExternalLink(issueLink.id, { 62 + title: event.newTitle, 63 + metadata: { 64 + ...metadata, 65 + lastSync: { 66 + ...metadata.lastSync, 67 + title: { 68 + timestamp: new Date().toISOString(), 69 + source: "kaneo", 70 + value: event.newTitle, 71 + }, 72 + }, 73 + }, 74 + }); 75 + 76 + console.log(`Synced task title to Gitea issue #${issueNumber}`); 77 + } catch (error) { 78 + console.error("Failed to update Gitea issue title:", error); 79 + } 80 + }
+20
apps/api/src/plugins/gitea/index.ts
··· 1 + import type { IntegrationPlugin } from "../types"; 2 + import { validateGiteaConfig } from "./config"; 3 + import { handleTaskCommentCreated } from "./events/task-comment-created"; 4 + import { handleTaskCreated } from "./events/task-created"; 5 + import { handleTaskDescriptionChanged } from "./events/task-description-changed"; 6 + import { handleTaskPriorityChanged } from "./events/task-priority-changed"; 7 + import { handleTaskStatusChanged } from "./events/task-status-changed"; 8 + import { handleTaskTitleChanged } from "./events/task-title-changed"; 9 + 10 + export const giteaPlugin: IntegrationPlugin = { 11 + type: "gitea", 12 + name: "Gitea", 13 + onTaskCreated: handleTaskCreated, 14 + onTaskStatusChanged: handleTaskStatusChanged, 15 + onTaskPriorityChanged: handleTaskPriorityChanged, 16 + onTaskTitleChanged: handleTaskTitleChanged, 17 + onTaskDescriptionChanged: handleTaskDescriptionChanged, 18 + onTaskCommentCreated: handleTaskCommentCreated, 19 + validateConfig: validateGiteaConfig, 20 + };
+41
apps/api/src/plugins/gitea/services/integration-lookup.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import db from "../../../database"; 3 + import { integrationTable } from "../../../database/schema"; 4 + import type { GiteaConfig } from "../config"; 5 + import { normalizeGiteaBaseUrl } from "../config"; 6 + 7 + export async function findAllIntegrationsByGiteaRepo( 8 + baseUrl: string, 9 + owner: string, 10 + repo: string, 11 + ) { 12 + const normalized = normalizeGiteaBaseUrl(baseUrl); 13 + const integrations = await db.query.integrationTable.findMany({ 14 + where: and( 15 + eq(integrationTable.type, "gitea"), 16 + eq(integrationTable.isActive, true), 17 + ), 18 + with: { 19 + project: true, 20 + }, 21 + }); 22 + 23 + return integrations.filter((integration) => { 24 + try { 25 + const config = JSON.parse(integration.config) as GiteaConfig; 26 + return ( 27 + normalizeGiteaBaseUrl(config.baseUrl) === normalized && 28 + config.repositoryOwner === owner && 29 + config.repositoryName === repo 30 + ); 31 + } catch { 32 + return false; 33 + } 34 + }); 35 + } 36 + 37 + export function repoOwnerLogin(repo: { 38 + owner?: { login?: string; username?: string }; 39 + }): string { 40 + return repo.owner?.login ?? repo.owner?.username ?? ""; 41 + }
+47
apps/api/src/plugins/gitea/utils/branch-matcher.ts
··· 1 + import type { GitHubConfig } from "../../github/config"; 2 + import { 3 + extractTaskNumber, 4 + extractTaskNumberFromBranch, 5 + extractTaskNumberFromPRBody, 6 + extractTaskNumberFromPRTitle, 7 + generateBranchName, 8 + } from "../../github/utils/branch-matcher"; 9 + import type { GiteaConfig } from "../config"; 10 + 11 + function asBranchConfig(config: GiteaConfig): GitHubConfig { 12 + return config as unknown as GitHubConfig; 13 + } 14 + 15 + export { 16 + extractTaskNumberFromPRBody, 17 + extractTaskNumberFromPRTitle, 18 + generateBranchName, 19 + }; 20 + 21 + export function extractTaskNumberFromBranchGitea( 22 + branchName: string, 23 + config: GiteaConfig, 24 + projectSlug: string, 25 + ): number | null { 26 + return extractTaskNumberFromBranch( 27 + branchName, 28 + asBranchConfig(config), 29 + projectSlug, 30 + ); 31 + } 32 + 33 + export function extractTaskNumberGitea( 34 + branchName: string, 35 + prTitle: string | undefined, 36 + prBody: string | undefined, 37 + config: GiteaConfig, 38 + projectSlug: string, 39 + ): number | null { 40 + return extractTaskNumber( 41 + branchName, 42 + prTitle, 43 + prBody, 44 + asBranchConfig(config), 45 + projectSlug, 46 + ); 47 + }
+311
apps/api/src/plugins/gitea/utils/gitea-api.ts
··· 1 + import type { GiteaConfig } from "../config"; 2 + import { normalizeGiteaBaseUrl } from "../config"; 3 + 4 + export type GiteaLabel = { 5 + id: number; 6 + name: string; 7 + color?: string; 8 + }; 9 + 10 + export type GiteaIssue = { 11 + id: number; 12 + number: number; 13 + title: string; 14 + body: string | null; 15 + html_url: string; 16 + state: string; 17 + labels?: GiteaLabel[]; 18 + user?: { login?: string; username?: string; avatar_url?: string } | null; 19 + pull_request?: unknown; 20 + }; 21 + 22 + export type GiteaComment = { 23 + id: number; 24 + body: string; 25 + html_url: string; 26 + user?: { login?: string; username?: string; avatar_url?: string } | null; 27 + created_at: string; 28 + }; 29 + 30 + export type GiteaPullRequest = { 31 + number: number; 32 + title: string; 33 + body: string | null; 34 + html_url: string; 35 + state: string; 36 + head?: { ref?: string }; 37 + user?: { login?: string; username?: string; avatar_url?: string } | null; 38 + merged?: boolean; 39 + merged_at?: string | null; 40 + }; 41 + 42 + export class GiteaApiError extends Error { 43 + constructor( 44 + message: string, 45 + public status: number, 46 + public body?: string, 47 + ) { 48 + super(message); 49 + this.name = "GiteaApiError"; 50 + } 51 + } 52 + 53 + function authHeaders(token: string): HeadersInit { 54 + return { 55 + Authorization: `token ${token}`, 56 + "Content-Type": "application/json", 57 + }; 58 + } 59 + 60 + export async function giteaFetch<T>( 61 + baseUrl: string, 62 + token: string, 63 + path: string, 64 + init?: RequestInit, 65 + ): Promise<T> { 66 + const root = normalizeGiteaBaseUrl(baseUrl); 67 + const url = `${root}/api/v1${path.startsWith("/") ? path : `/${path}`}`; 68 + const res = await fetch(url, { 69 + ...init, 70 + headers: { 71 + ...authHeaders(token), 72 + ...init?.headers, 73 + }, 74 + }); 75 + 76 + const text = await res.text(); 77 + if (!res.ok) { 78 + throw new GiteaApiError(`Gitea API error ${res.status}`, res.status, text); 79 + } 80 + 81 + if (res.status === 204 || text === "") { 82 + return undefined as T; 83 + } 84 + 85 + try { 86 + return JSON.parse(text) as T; 87 + } catch { 88 + return undefined as T; 89 + } 90 + } 91 + 92 + export function createGiteaClient( 93 + config: Pick<GiteaConfig, "baseUrl" | "accessToken">, 94 + ) { 95 + const { baseUrl, accessToken } = config; 96 + const owner = (o: string, r: string) => 97 + `/repos/${encodeURIComponent(o)}/${encodeURIComponent(r)}`; 98 + 99 + return { 100 + async getRepo(repositoryOwner: string, repositoryName: string) { 101 + return giteaFetch<{ 102 + name: string; 103 + owner: { login?: string; username?: string }; 104 + html_url: string; 105 + private: boolean; 106 + permissions?: { admin?: boolean; push?: boolean; pull?: boolean }; 107 + }>(baseUrl, accessToken, owner(repositoryOwner, repositoryName)); 108 + }, 109 + 110 + async listUserRepos(page = 1, limit = 50) { 111 + return giteaFetch< 112 + Array<{ 113 + id: number; 114 + name: string; 115 + full_name: string; 116 + owner: { login?: string; username?: string }; 117 + private: boolean; 118 + html_url: string; 119 + }> 120 + >(baseUrl, accessToken, `/user/repos?page=${page}&limit=${limit}`); 121 + }, 122 + 123 + async createIssue( 124 + repositoryOwner: string, 125 + repositoryName: string, 126 + body: { title: string; body?: string | null; closed?: boolean }, 127 + ) { 128 + return giteaFetch<GiteaIssue>( 129 + baseUrl, 130 + accessToken, 131 + `${owner(repositoryOwner, repositoryName)}/issues`, 132 + { 133 + method: "POST", 134 + body: JSON.stringify(body), 135 + }, 136 + ); 137 + }, 138 + 139 + async updateIssue( 140 + repositoryOwner: string, 141 + repositoryName: string, 142 + index: number, 143 + body: Record<string, unknown>, 144 + ) { 145 + return giteaFetch<GiteaIssue>( 146 + baseUrl, 147 + accessToken, 148 + `${owner(repositoryOwner, repositoryName)}/issues/${index}`, 149 + { 150 + method: "PATCH", 151 + body: JSON.stringify(body), 152 + }, 153 + ); 154 + }, 155 + 156 + async listIssueComments( 157 + repositoryOwner: string, 158 + repositoryName: string, 159 + index: number, 160 + page: number, 161 + limit: number, 162 + ) { 163 + return giteaFetch<GiteaComment[]>( 164 + baseUrl, 165 + accessToken, 166 + `${owner(repositoryOwner, repositoryName)}/issues/${index}/comments?page=${page}&limit=${limit}`, 167 + ); 168 + }, 169 + 170 + async createIssueComment( 171 + repositoryOwner: string, 172 + repositoryName: string, 173 + index: number, 174 + body: string, 175 + ) { 176 + return giteaFetch<GiteaComment>( 177 + baseUrl, 178 + accessToken, 179 + `${owner(repositoryOwner, repositoryName)}/issues/${index}/comments`, 180 + { 181 + method: "POST", 182 + body: JSON.stringify({ body }), 183 + }, 184 + ); 185 + }, 186 + 187 + async listLabels(repositoryOwner: string, repositoryName: string) { 188 + return giteaFetch<GiteaLabel[]>( 189 + baseUrl, 190 + accessToken, 191 + `${owner(repositoryOwner, repositoryName)}/labels`, 192 + ); 193 + }, 194 + 195 + async createLabel( 196 + repositoryOwner: string, 197 + repositoryName: string, 198 + name: string, 199 + color: string, 200 + ) { 201 + return giteaFetch<GiteaLabel>( 202 + baseUrl, 203 + accessToken, 204 + `${owner(repositoryOwner, repositoryName)}/labels`, 205 + { 206 + method: "POST", 207 + body: JSON.stringify({ 208 + name, 209 + color: color.replace(/^#/, ""), 210 + }), 211 + }, 212 + ); 213 + }, 214 + 215 + async addLabelsToIssue( 216 + repositoryOwner: string, 217 + repositoryName: string, 218 + index: number, 219 + labelIds: number[], 220 + ) { 221 + if (labelIds.length === 0) return; 222 + await giteaFetch<unknown>( 223 + baseUrl, 224 + accessToken, 225 + `${owner(repositoryOwner, repositoryName)}/issues/${index}/labels`, 226 + { 227 + method: "POST", 228 + body: JSON.stringify(labelIds), 229 + }, 230 + ); 231 + }, 232 + 233 + async replaceIssueLabels( 234 + repositoryOwner: string, 235 + repositoryName: string, 236 + index: number, 237 + labelIds: number[], 238 + ) { 239 + await giteaFetch<unknown>( 240 + baseUrl, 241 + accessToken, 242 + `${owner(repositoryOwner, repositoryName)}/issues/${index}/labels`, 243 + { 244 + method: "PUT", 245 + body: JSON.stringify(labelIds), 246 + }, 247 + ); 248 + }, 249 + 250 + async removeLabelFromIssue( 251 + repositoryOwner: string, 252 + repositoryName: string, 253 + index: number, 254 + labelId: number, 255 + ) { 256 + await giteaFetch<unknown>( 257 + baseUrl, 258 + accessToken, 259 + `${owner(repositoryOwner, repositoryName)}/issues/${index}/labels/${labelId}`, 260 + { 261 + method: "DELETE", 262 + }, 263 + ); 264 + }, 265 + 266 + async getIssue( 267 + repositoryOwner: string, 268 + repositoryName: string, 269 + index: number, 270 + ) { 271 + return giteaFetch<GiteaIssue>( 272 + baseUrl, 273 + accessToken, 274 + `${owner(repositoryOwner, repositoryName)}/issues/${index}`, 275 + ); 276 + }, 277 + 278 + async listIssues( 279 + repositoryOwner: string, 280 + repositoryName: string, 281 + page: number, 282 + state: "open" | "closed" | "all", 283 + ) { 284 + return giteaFetch<GiteaIssue[]>( 285 + baseUrl, 286 + accessToken, 287 + `${owner(repositoryOwner, repositoryName)}/issues?state=${state}&page=${page}&limit=100`, 288 + ); 289 + }, 290 + 291 + async listPulls( 292 + repositoryOwner: string, 293 + repositoryName: string, 294 + page: number, 295 + ) { 296 + return giteaFetch<GiteaPullRequest[]>( 297 + baseUrl, 298 + accessToken, 299 + `${owner(repositoryOwner, repositoryName)}/pulls?state=open&page=${page}&limit=100`, 300 + ); 301 + }, 302 + }; 303 + } 304 + 305 + export async function verifyGiteaToken(baseUrl: string, token: string) { 306 + return giteaFetch<{ id: number; login: string }>( 307 + normalizeGiteaBaseUrl(baseUrl), 308 + token, 309 + "/user", 310 + ); 311 + }
+125
apps/api/src/plugins/gitea/utils/labels.ts
··· 1 + import type { GiteaConfig } from "../config"; 2 + import { createGiteaClient, type GiteaLabel } from "./gitea-api"; 3 + 4 + const labelColors: Record<string, string> = { 5 + "priority:low": "0EA5E9", 6 + "priority:medium": "EAB308", 7 + "priority:high": "F97316", 8 + "priority:urgent": "EF4444", 9 + "status:to-do": "6B7280", 10 + "status:in-progress": "3B82F6", 11 + "status:in-review": "8B5CF6", 12 + "status:done": "10B981", 13 + "status:planned": "8B5CF6", 14 + "status:archived": "6B7280", 15 + }; 16 + 17 + function getLabelColor(labelName: string): string { 18 + return labelColors[labelName] || "6B7280"; 19 + } 20 + 21 + async function getOrCreateLabelId( 22 + client: ReturnType<typeof createGiteaClient>, 23 + config: Pick<GiteaConfig, "repositoryOwner" | "repositoryName">, 24 + name: string, 25 + ): Promise<number> { 26 + const { repositoryOwner, repositoryName } = config; 27 + let labels: GiteaLabel[]; 28 + try { 29 + labels = await client.listLabels(repositoryOwner, repositoryName); 30 + } catch { 31 + labels = []; 32 + } 33 + 34 + const found = labels.find((l) => l.name === name); 35 + if (found) { 36 + return found.id; 37 + } 38 + 39 + const color = getLabelColor(name); 40 + const created = await client.createLabel( 41 + repositoryOwner, 42 + repositoryName, 43 + name, 44 + color, 45 + ); 46 + return created.id; 47 + } 48 + 49 + export async function ensureLabelsExistGitea( 50 + config: GiteaConfig, 51 + labels: string[], 52 + ): Promise<Map<string, number>> { 53 + const client = createGiteaClient(config); 54 + const map = new Map<string, number>(); 55 + for (const name of labels) { 56 + try { 57 + const id = await getOrCreateLabelId(client, config, name); 58 + map.set(name, id); 59 + } catch (error) { 60 + console.error(`Failed to ensure Gitea label "${name}":`, error); 61 + } 62 + } 63 + return map; 64 + } 65 + 66 + export async function addLabelsToIssueGitea( 67 + config: GiteaConfig, 68 + issueIndex: number, 69 + labelNames: string[], 70 + ) { 71 + if (labelNames.length === 0) return; 72 + 73 + const client = createGiteaClient(config); 74 + const ids: number[] = []; 75 + for (const name of labelNames) { 76 + try { 77 + const id = await getOrCreateLabelId(client, config, name); 78 + ids.push(id); 79 + } catch (error) { 80 + console.error(`Failed to add Gitea label "${name}":`, error); 81 + } 82 + } 83 + 84 + if (ids.length === 0) return; 85 + 86 + try { 87 + await client.addLabelsToIssue( 88 + config.repositoryOwner, 89 + config.repositoryName, 90 + issueIndex, 91 + ids, 92 + ); 93 + } catch (error) { 94 + console.error("Failed to add labels to Gitea issue:", error); 95 + } 96 + } 97 + 98 + export async function removeLabelGitea( 99 + config: GiteaConfig, 100 + issueIndex: number, 101 + labelName: string, 102 + ) { 103 + const client = createGiteaClient(config); 104 + let labels: GiteaLabel[]; 105 + try { 106 + labels = await client.listLabels( 107 + config.repositoryOwner, 108 + config.repositoryName, 109 + ); 110 + } catch { 111 + return; 112 + } 113 + 114 + const label = labels.find((l) => l.name === labelName); 115 + if (!label) return; 116 + 117 + try { 118 + await client.removeLabelFromIssue( 119 + config.repositoryOwner, 120 + config.repositoryName, 121 + issueIndex, 122 + label.id, 123 + ); 124 + } catch {} 125 + }
+48
apps/api/src/plugins/gitea/utils/resolve-column.ts
··· 1 + import { and, asc, eq } from "drizzle-orm"; 2 + import db from "../../../database"; 3 + import { columnTable, workflowRuleTable } from "../../../database/schema"; 4 + 5 + export async function resolveTargetStatus( 6 + projectId: string, 7 + eventType: string, 8 + fallbackStatus: string, 9 + ): Promise<string> { 10 + const projectColumns = await db 11 + .select({ 12 + id: columnTable.id, 13 + slug: columnTable.slug, 14 + }) 15 + .from(columnTable) 16 + .where(eq(columnTable.projectId, projectId)) 17 + .orderBy(asc(columnTable.position)); 18 + 19 + if (projectColumns.length === 0) { 20 + return fallbackStatus; 21 + } 22 + 23 + const rule = await db.query.workflowRuleTable.findFirst({ 24 + where: and( 25 + eq(workflowRuleTable.projectId, projectId), 26 + eq(workflowRuleTable.integrationType, "gitea"), 27 + eq(workflowRuleTable.eventType, eventType), 28 + ), 29 + }); 30 + 31 + if (rule) { 32 + const mappedColumn = projectColumns.find( 33 + (column) => column.id === rule.columnId, 34 + ); 35 + if (mappedColumn) { 36 + return mappedColumn.slug; 37 + } 38 + } 39 + 40 + const fallbackColumn = projectColumns.find( 41 + (column) => column.slug === fallbackStatus, 42 + ); 43 + if (fallbackColumn) { 44 + return fallbackColumn.slug; 45 + } 46 + 47 + return projectColumns[0]?.slug ?? fallbackStatus; 48 + }
+163
apps/api/src/plugins/gitea/utils/sync-label-to-gitea.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import db from "../../../database"; 3 + import { externalLinkTable } from "../../../database/schema"; 4 + import type { GiteaConfig } from "../config"; 5 + import { createGiteaClient } from "./gitea-api"; 6 + 7 + const namedColorToHex: Record<string, string> = { 8 + red: "EF4444", 9 + orange: "F97316", 10 + amber: "F59E0B", 11 + yellow: "EAB308", 12 + lime: "84CC16", 13 + green: "22C55E", 14 + emerald: "10B981", 15 + teal: "14B8A6", 16 + cyan: "06B6D4", 17 + sky: "0EA5E9", 18 + blue: "3B82F6", 19 + indigo: "6366F1", 20 + violet: "8B5CF6", 21 + purple: "A855F7", 22 + fuchsia: "D946EF", 23 + pink: "EC4899", 24 + rose: "F43F5E", 25 + gray: "6B7280", 26 + slate: "64748B", 27 + zinc: "71717A", 28 + neutral: "737373", 29 + stone: "78716C", 30 + }; 31 + 32 + function toHexColor(color: string): string { 33 + const lower = color.toLowerCase().replace(/^#/, ""); 34 + if (namedColorToHex[lower]) { 35 + return namedColorToHex[lower]; 36 + } 37 + if (/^[0-9a-f]{6}$/i.test(lower)) { 38 + return lower; 39 + } 40 + if (/^[0-9a-f]{3}$/i.test(lower)) { 41 + const [r, g, b] = lower.split(""); 42 + return `${r}${r}${g}${g}${b}${b}`; 43 + } 44 + return "6B7280"; 45 + } 46 + 47 + async function getGiteaIssueContext(taskId: string) { 48 + const externalLink = await db.query.externalLinkTable.findFirst({ 49 + where: eq(externalLinkTable.taskId, taskId), 50 + with: { 51 + integration: true, 52 + }, 53 + }); 54 + 55 + if (!externalLink || externalLink.resourceType !== "issue") { 56 + return null; 57 + } 58 + 59 + const integration = externalLink.integration; 60 + if (!integration || integration.type !== "gitea") { 61 + return null; 62 + } 63 + 64 + let config: GiteaConfig; 65 + try { 66 + config = JSON.parse(integration.config) as GiteaConfig; 67 + } catch { 68 + return null; 69 + } 70 + 71 + if (!config.accessToken || !config.baseUrl) { 72 + return null; 73 + } 74 + 75 + const client = createGiteaClient(config); 76 + const issueNumber = Number.parseInt(externalLink.externalId, 10); 77 + 78 + return { 79 + client, 80 + config, 81 + issueNumber, 82 + }; 83 + } 84 + 85 + export async function syncLabelToGitea( 86 + taskId: string, 87 + labelName: string, 88 + labelColor: string, 89 + ) { 90 + const ctx = await getGiteaIssueContext(taskId); 91 + if (!ctx) return; 92 + 93 + const { client, config, issueNumber } = ctx; 94 + const color = toHexColor(labelColor); 95 + 96 + const labels = await client.listLabels( 97 + config.repositoryOwner, 98 + config.repositoryName, 99 + ); 100 + let label = labels.find((l) => l.name === labelName); 101 + 102 + if (!label) { 103 + try { 104 + label = await client.createLabel( 105 + config.repositoryOwner, 106 + config.repositoryName, 107 + labelName, 108 + color, 109 + ); 110 + } catch (error) { 111 + console.error(`Failed to create label "${labelName}" in Gitea:`, error); 112 + return; 113 + } 114 + } 115 + 116 + try { 117 + const issue = await client.getIssue( 118 + config.repositoryOwner, 119 + config.repositoryName, 120 + issueNumber, 121 + ); 122 + const existingIds = (issue.labels ?? []).map((l) => l.id); 123 + if (existingIds.includes(label.id)) { 124 + return; 125 + } 126 + await client.addLabelsToIssue( 127 + config.repositoryOwner, 128 + config.repositoryName, 129 + issueNumber, 130 + [label.id], 131 + ); 132 + } catch (error) { 133 + console.error(`Failed to add label "${labelName}" to Gitea issue:`, error); 134 + } 135 + } 136 + 137 + export async function removeLabelFromGitea(taskId: string, labelName: string) { 138 + const ctx = await getGiteaIssueContext(taskId); 139 + if (!ctx) return; 140 + 141 + const { client, config, issueNumber } = ctx; 142 + 143 + const labels = await client.listLabels( 144 + config.repositoryOwner, 145 + config.repositoryName, 146 + ); 147 + const label = labels.find((l) => l.name === labelName); 148 + if (!label) return; 149 + 150 + try { 151 + await client.removeLabelFromIssue( 152 + config.repositoryOwner, 153 + config.repositoryName, 154 + issueNumber, 155 + label.id, 156 + ); 157 + } catch (error) { 158 + console.error( 159 + `Failed to remove label "${labelName}" from Gitea issue:`, 160 + error, 161 + ); 162 + } 163 + }
+29
apps/api/src/plugins/gitea/utils/verify-signature.ts
··· 1 + import { createHmac, timingSafeEqual } from "node:crypto"; 2 + 3 + export function verifyGiteaSignature( 4 + payload: string, 5 + secret: string, 6 + signatureHeader: string | undefined, 7 + ): boolean { 8 + if (!signatureHeader || !secret) { 9 + return false; 10 + } 11 + 12 + let provided = signatureHeader.trim(); 13 + if (provided.toLowerCase().startsWith("sha256=")) { 14 + provided = provided.slice(7); 15 + } 16 + 17 + const expected = createHmac("sha256", secret).update(payload).digest("hex"); 18 + 19 + try { 20 + const a = Buffer.from(provided, "hex"); 21 + const b = Buffer.from(expected, "hex"); 22 + if (a.length !== b.length) { 23 + return false; 24 + } 25 + return timingSafeEqual(a, b); 26 + } catch { 27 + return provided === expected; 28 + } 29 + }
+10
apps/api/src/plugins/gitea/utils/webhook-repo.ts
··· 1 + import { normalizeGiteaBaseUrl } from "../config"; 2 + 3 + export function baseUrlFromRepositoryHtmlUrl(htmlUrl: string): string { 4 + try { 5 + const u = new URL(htmlUrl); 6 + return normalizeGiteaBaseUrl(`${u.protocol}//${u.host}`); 7 + } catch { 8 + return ""; 9 + } 10 + }
+129
apps/api/src/plugins/gitea/webhook-handler.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import db from "../../database"; 3 + import { integrationTable } from "../../database/schema"; 4 + import type { GiteaConfig } from "./config"; 5 + import { verifyGiteaSignature } from "./utils/verify-signature"; 6 + import { handleGiteaIssueClosed } from "./webhooks/issue-closed"; 7 + import { handleGiteaIssueCommentCreated } from "./webhooks/issue-comment-created"; 8 + import { handleGiteaIssueEdited } from "./webhooks/issue-edited"; 9 + import { handleGiteaIssueLabeled } from "./webhooks/issue-labeled"; 10 + import { handleGiteaIssueOpened } from "./webhooks/issue-opened"; 11 + import { handleGiteaLabelCreated } from "./webhooks/label-created"; 12 + import { handleGiteaPullRequestClosed } from "./webhooks/pull-request-closed"; 13 + import { handleGiteaPullRequestOpened } from "./webhooks/pull-request-opened"; 14 + import { handleGiteaPush } from "./webhooks/push"; 15 + 16 + export async function handleGiteaWebhookRequest( 17 + integrationId: string, 18 + rawBody: string, 19 + signatureHeader: string | undefined, 20 + eventHeader: string | undefined, 21 + ): Promise<{ success: boolean; error?: string }> { 22 + const integration = await db.query.integrationTable.findFirst({ 23 + where: eq(integrationTable.id, integrationId), 24 + }); 25 + 26 + if (!integration || integration.type !== "gitea") { 27 + return { success: false, error: "Gitea integration not found" }; 28 + } 29 + 30 + let config: GiteaConfig; 31 + try { 32 + config = JSON.parse(integration.config) as GiteaConfig; 33 + } catch { 34 + return { success: false, error: "Invalid integration config" }; 35 + } 36 + 37 + const secret = config.webhookSecret; 38 + if (!secret) { 39 + return { success: false, error: "Webhook secret not configured" }; 40 + } 41 + 42 + if (!verifyGiteaSignature(rawBody, secret, signatureHeader)) { 43 + return { success: false, error: "Invalid webhook signature" }; 44 + } 45 + 46 + const event = eventHeader || undefined; 47 + 48 + if (!event) { 49 + return { success: false, error: "Missing event name" }; 50 + } 51 + 52 + let payload: Record<string, unknown>; 53 + try { 54 + payload = JSON.parse(rawBody) as Record<string, unknown>; 55 + } catch { 56 + return { success: false, error: "Invalid JSON payload" }; 57 + } 58 + 59 + try { 60 + await dispatchGiteaEvent(event, payload); 61 + return { success: true }; 62 + } catch (error) { 63 + console.error("[Gitea Webhook] Handler error:", error); 64 + return { 65 + success: false, 66 + error: error instanceof Error ? error.message : "Webhook handler failed", 67 + }; 68 + } 69 + } 70 + 71 + async function dispatchGiteaEvent( 72 + event: string, 73 + payload: Record<string, unknown>, 74 + ) { 75 + console.log(`[Gitea Webhook] Event: ${event}`); 76 + 77 + switch (event) { 78 + case "push": 79 + await handleGiteaPush(payload as never); 80 + return; 81 + case "pull_request": { 82 + const action = payload.action as string | undefined; 83 + if ( 84 + action === "opened" || 85 + action === "reopened" || 86 + action === "ready_for_review" 87 + ) { 88 + await handleGiteaPullRequestOpened(payload as never); 89 + } else if (action === "closed") { 90 + await handleGiteaPullRequestClosed(payload as never); 91 + } 92 + return; 93 + } 94 + case "issues": { 95 + const action = payload.action as string | undefined; 96 + // Gitea uses "created" for new issues; GitHub-style is "opened" 97 + if (action === "opened" || action === "created") { 98 + await handleGiteaIssueOpened(payload as never); 99 + } else if (action === "closed") { 100 + await handleGiteaIssueClosed(payload as never); 101 + } else if (action === "edited") { 102 + await handleGiteaIssueEdited(payload as never); 103 + } else if ( 104 + action === "labeled" || 105 + action === "unlabeled" || 106 + action === "label_updated" 107 + ) { 108 + await handleGiteaIssueLabeled({ 109 + ...payload, 110 + action: action ?? "", 111 + } as never); 112 + } 113 + return; 114 + } 115 + case "issue_comment": { 116 + const action = payload.action as string | undefined; 117 + if (action === "created") { 118 + await handleGiteaIssueCommentCreated(payload as never); 119 + } 120 + return; 121 + } 122 + case "create": { 123 + await handleGiteaLabelCreated(payload as never); 124 + return; 125 + } 126 + default: 127 + console.log(`[Gitea Webhook] Ignored event: ${event}`); 128 + } 129 + }
+87
apps/api/src/plugins/gitea/webhooks/issue-closed.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import db from "../../../database"; 3 + import { externalLinkTable, taskTable } from "../../../database/schema"; 4 + import { updateExternalLink } from "../../github/services/link-manager"; 5 + import { updateTaskStatus } from "../../github/services/task-service"; 6 + import { 7 + findAllIntegrationsByGiteaRepo, 8 + repoOwnerLogin, 9 + } from "../services/integration-lookup"; 10 + import { resolveTargetStatus } from "../utils/resolve-column"; 11 + import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo"; 12 + 13 + type IssueClosedPayload = { 14 + action: string; 15 + issue: { 16 + number: number; 17 + title: string; 18 + html_url: string; 19 + state: string; 20 + }; 21 + repository: { 22 + owner: { login?: string; username?: string }; 23 + name: string; 24 + html_url: string; 25 + }; 26 + }; 27 + 28 + export async function handleGiteaIssueClosed(payload: IssueClosedPayload) { 29 + const { issue, repository } = payload; 30 + 31 + const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url); 32 + if (!baseUrl) return; 33 + 34 + const owner = repoOwnerLogin(repository); 35 + const integrations = await findAllIntegrationsByGiteaRepo( 36 + baseUrl, 37 + owner, 38 + repository.name, 39 + ); 40 + 41 + for (const integration of integrations) { 42 + const externalLink = await db.query.externalLinkTable.findFirst({ 43 + where: and( 44 + eq(externalLinkTable.integrationId, integration.id), 45 + eq(externalLinkTable.resourceType, "issue"), 46 + eq(externalLinkTable.externalId, issue.number.toString()), 47 + ), 48 + }); 49 + 50 + if (!externalLink) { 51 + continue; 52 + } 53 + 54 + const task = await db.query.taskTable.findFirst({ 55 + where: eq(taskTable.id, externalLink.taskId), 56 + }); 57 + 58 + if (!task) { 59 + continue; 60 + } 61 + 62 + const existingMetadata = externalLink.metadata 63 + ? JSON.parse(externalLink.metadata) 64 + : {}; 65 + 66 + if (existingMetadata.createdFrom === "kaneo") { 67 + continue; 68 + } 69 + 70 + const targetStatus = await resolveTargetStatus( 71 + task.projectId, 72 + "issue_closed", 73 + "done", 74 + ); 75 + 76 + await updateTaskStatus(task.id, targetStatus); 77 + 78 + await updateExternalLink(externalLink.id, { 79 + metadata: { 80 + ...existingMetadata, 81 + state: "closed", 82 + }, 83 + }); 84 + 85 + return; 86 + } 87 + }
+80
apps/api/src/plugins/gitea/webhooks/issue-comment-created.ts
··· 1 + import db from "../../../database"; 2 + import { activityTable } from "../../../database/schema"; 3 + import { findExternalLink } from "../../github/services/link-manager"; 4 + import { 5 + findAllIntegrationsByGiteaRepo, 6 + repoOwnerLogin, 7 + } from "../services/integration-lookup"; 8 + import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo"; 9 + 10 + type IssueCommentCreatedPayload = { 11 + action: string; 12 + issue: { 13 + number: number; 14 + }; 15 + comment: { 16 + id: number; 17 + body: string; 18 + html_url: string; 19 + user: { 20 + login?: string; 21 + username?: string; 22 + avatar_url: string; 23 + } | null; 24 + created_at: string; 25 + }; 26 + repository: { 27 + owner: { login?: string; username?: string }; 28 + name: string; 29 + html_url: string; 30 + }; 31 + }; 32 + 33 + export async function handleGiteaIssueCommentCreated( 34 + payload: IssueCommentCreatedPayload, 35 + ) { 36 + const { issue, comment, repository } = payload; 37 + 38 + if (payload.action !== "created") { 39 + return; 40 + } 41 + 42 + const username = comment.user?.login ?? comment.user?.username ?? ""; 43 + if (username.endsWith("[bot]")) { 44 + return; 45 + } 46 + 47 + const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url); 48 + if (!baseUrl) return; 49 + 50 + const owner = repoOwnerLogin(repository); 51 + const integrations = await findAllIntegrationsByGiteaRepo( 52 + baseUrl, 53 + owner, 54 + repository.name, 55 + ); 56 + 57 + for (const integration of integrations) { 58 + const existingLink = await findExternalLink( 59 + integration.id, 60 + "issue", 61 + issue.number.toString(), 62 + ); 63 + 64 + if (!existingLink) { 65 + continue; 66 + } 67 + 68 + await db.insert(activityTable).values({ 69 + taskId: existingLink.taskId, 70 + type: "comment", 71 + content: comment.body, 72 + externalUserName: username || "Unknown", 73 + externalUserAvatar: comment.user?.avatar_url ?? null, 74 + externalSource: "gitea", 75 + externalUrl: comment.html_url, 76 + }); 77 + 78 + return; 79 + } 80 + }
+156
apps/api/src/plugins/gitea/webhooks/issue-edited.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import db from "../../../database"; 3 + import { taskTable } from "../../../database/schema"; 4 + import { 5 + findExternalLink, 6 + updateExternalLink, 7 + } from "../../github/services/link-manager"; 8 + import { formatTaskDescriptionFromIssue } from "../../github/utils/format"; 9 + import { 10 + findAllIntegrationsByGiteaRepo, 11 + repoOwnerLogin, 12 + } from "../services/integration-lookup"; 13 + import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo"; 14 + 15 + type IssueEditedPayload = { 16 + action: string; 17 + issue: { 18 + number: number; 19 + title: string; 20 + body: string | null; 21 + html_url: string; 22 + }; 23 + changes?: { 24 + title?: { from: string }; 25 + body?: { from: string }; 26 + }; 27 + repository: { 28 + owner: { login?: string; username?: string }; 29 + name: string; 30 + html_url: string; 31 + }; 32 + }; 33 + 34 + export async function handleGiteaIssueEdited(payload: IssueEditedPayload) { 35 + const { issue, repository, changes } = payload; 36 + 37 + if (!changes?.title && !changes?.body) { 38 + return; 39 + } 40 + 41 + const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url); 42 + if (!baseUrl) return; 43 + 44 + const owner = repoOwnerLogin(repository); 45 + const integrations = await findAllIntegrationsByGiteaRepo( 46 + baseUrl, 47 + owner, 48 + repository.name, 49 + ); 50 + 51 + for (const integration of integrations) { 52 + const externalLink = await findExternalLink( 53 + integration.id, 54 + "issue", 55 + issue.number.toString(), 56 + ); 57 + 58 + if (!externalLink) { 59 + continue; 60 + } 61 + 62 + const task = await db.query.taskTable.findFirst({ 63 + where: eq(taskTable.id, externalLink.taskId), 64 + }); 65 + 66 + if (!task) { 67 + continue; 68 + } 69 + 70 + const metadata = externalLink.metadata 71 + ? JSON.parse(externalLink.metadata) 72 + : {}; 73 + 74 + const updateData: Record<string, unknown> = {}; 75 + const updatedMetadata = { ...metadata }; 76 + 77 + if (!updatedMetadata.lastSync) { 78 + updatedMetadata.lastSync = {}; 79 + } 80 + 81 + if (changes.title) { 82 + const lastTitleSync = metadata.lastSync?.title; 83 + 84 + let shouldUpdateTitle = true; 85 + 86 + if (lastTitleSync) { 87 + if ( 88 + lastTitleSync.value === issue.title && 89 + lastTitleSync.source === "kaneo" 90 + ) { 91 + shouldUpdateTitle = false; 92 + } 93 + 94 + const timeSinceLastSync = 95 + Date.now() - new Date(lastTitleSync.timestamp).getTime(); 96 + if (timeSinceLastSync < 2000 && shouldUpdateTitle) { 97 + shouldUpdateTitle = false; 98 + } 99 + } 100 + 101 + if (shouldUpdateTitle) { 102 + updateData.title = issue.title; 103 + updatedMetadata.lastSync.title = { 104 + timestamp: new Date().toISOString(), 105 + source: "gitea", 106 + value: issue.title, 107 + }; 108 + } 109 + } 110 + 111 + if (changes.body) { 112 + const lastDescSync = metadata.lastSync?.description; 113 + const formattedDescription = formatTaskDescriptionFromIssue(issue.body); 114 + 115 + let shouldUpdateDescription = true; 116 + 117 + if (lastDescSync) { 118 + if ( 119 + lastDescSync.value === formattedDescription && 120 + lastDescSync.source === "kaneo" 121 + ) { 122 + shouldUpdateDescription = false; 123 + } 124 + 125 + const timeSinceLastSync = 126 + Date.now() - new Date(lastDescSync.timestamp).getTime(); 127 + if (timeSinceLastSync < 2000 && shouldUpdateDescription) { 128 + shouldUpdateDescription = false; 129 + } 130 + } 131 + 132 + if (shouldUpdateDescription) { 133 + updateData.description = formattedDescription; 134 + updatedMetadata.lastSync.description = { 135 + timestamp: new Date().toISOString(), 136 + source: "gitea", 137 + value: formattedDescription, 138 + }; 139 + } 140 + } 141 + 142 + if (Object.keys(updateData).length > 0) { 143 + await db 144 + .update(taskTable) 145 + .set(updateData) 146 + .where(eq(taskTable.id, task.id)); 147 + 148 + await updateExternalLink(externalLink.id, { 149 + title: issue.title, 150 + metadata: updatedMetadata, 151 + }); 152 + } 153 + 154 + return; 155 + } 156 + }
+196
apps/api/src/plugins/gitea/webhooks/issue-labeled.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import db from "../../../database"; 3 + import { labelTable, taskTable } from "../../../database/schema"; 4 + import { findExternalLink } from "../../github/services/link-manager"; 5 + import { updateTaskStatus } from "../../github/services/task-service"; 6 + import { 7 + extractIssuePriority, 8 + extractIssueStatus, 9 + } from "../../github/utils/extract-priority"; 10 + import { 11 + findAllIntegrationsByGiteaRepo, 12 + repoOwnerLogin, 13 + } from "../services/integration-lookup"; 14 + import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo"; 15 + 16 + type IssueLabeledPayload = { 17 + action: string; 18 + issue: { 19 + number: number; 20 + labels?: Array<string | { name?: string; color?: string }>; 21 + }; 22 + label?: { 23 + name: string; 24 + color: string; 25 + }; 26 + repository: { 27 + owner: { login?: string; username?: string }; 28 + name: string; 29 + html_url: string; 30 + }; 31 + }; 32 + 33 + function isSystemLabelName(name: string) { 34 + return name.startsWith("priority:") || name.startsWith("status:"); 35 + } 36 + 37 + /** Non-system labels from a Gitea issue (used when action is label_updated). */ 38 + function giteaLabelsForSync( 39 + labels: IssueLabeledPayload["issue"]["labels"], 40 + ): Array<{ name: string; color?: string }> { 41 + if (!labels) return []; 42 + const out: Array<{ name: string; color?: string }> = []; 43 + for (const raw of labels) { 44 + const name = typeof raw === "string" ? raw : raw.name; 45 + if (!name || isSystemLabelName(name)) continue; 46 + const color = 47 + typeof raw === "object" && raw && "color" in raw ? raw.color : undefined; 48 + out.push({ name, color }); 49 + } 50 + return out; 51 + } 52 + 53 + async function syncGiteaLabelsToTask( 54 + taskId: string, 55 + workspaceId: string, 56 + giteaLabels: Array<{ name: string; color?: string }>, 57 + ) { 58 + const desiredNames = new Set(giteaLabels.map((l) => l.name)); 59 + const existingRows = await db.query.labelTable.findMany({ 60 + where: eq(labelTable.taskId, taskId), 61 + }); 62 + 63 + for (const g of giteaLabels) { 64 + const already = existingRows.some((row) => row.name === g.name); 65 + if (!already) { 66 + const color = g.color ? `#${g.color.replace(/^#/, "")}` : "#6B7280"; 67 + await db.insert(labelTable).values({ 68 + name: g.name, 69 + color, 70 + taskId, 71 + workspaceId, 72 + }); 73 + } 74 + } 75 + 76 + for (const row of existingRows) { 77 + if (!desiredNames.has(row.name) && !isSystemLabelName(row.name)) { 78 + await db.delete(labelTable).where(eq(labelTable.id, row.id)); 79 + } 80 + } 81 + } 82 + 83 + export async function handleGiteaIssueLabeled(payload: IssueLabeledPayload) { 84 + const { issue, repository, label: addedLabel } = payload; 85 + 86 + const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url); 87 + if (!baseUrl) return; 88 + 89 + const owner = repoOwnerLogin(repository); 90 + const integrations = await findAllIntegrationsByGiteaRepo( 91 + baseUrl, 92 + owner, 93 + repository.name, 94 + ); 95 + 96 + for (const integration of integrations) { 97 + const existingLink = await findExternalLink( 98 + integration.id, 99 + "issue", 100 + issue.number.toString(), 101 + ); 102 + 103 + if (!existingLink) { 104 + continue; 105 + } 106 + 107 + const priority = extractIssuePriority(issue.labels); 108 + const status = extractIssueStatus(issue.labels); 109 + 110 + if (priority) { 111 + await db 112 + .update(taskTable) 113 + .set({ priority }) 114 + .where(eq(taskTable.id, existingLink.taskId)); 115 + } 116 + 117 + if (status) { 118 + await updateTaskStatus(existingLink.taskId, status); 119 + } 120 + 121 + if (payload.action === "label_updated") { 122 + const task = await db.query.taskTable.findFirst({ 123 + where: eq(taskTable.id, existingLink.taskId), 124 + with: { 125 + project: true, 126 + }, 127 + }); 128 + if (task?.project?.workspaceId) { 129 + await syncGiteaLabelsToTask( 130 + existingLink.taskId, 131 + task.project.workspaceId, 132 + giteaLabelsForSync(issue.labels), 133 + ); 134 + } 135 + continue; 136 + } 137 + 138 + if (!addedLabel) { 139 + continue; 140 + } 141 + 142 + const isSystemLabel = 143 + addedLabel.name.startsWith("priority:") || 144 + addedLabel.name.startsWith("status:"); 145 + 146 + if (isSystemLabel) { 147 + continue; 148 + } 149 + 150 + if (payload.action === "labeled") { 151 + const task = await db.query.taskTable.findFirst({ 152 + where: eq(taskTable.id, existingLink.taskId), 153 + with: { 154 + project: true, 155 + }, 156 + }); 157 + 158 + if (task?.project?.workspaceId) { 159 + const existingLabel = await db.query.labelTable.findFirst({ 160 + where: (table, { and, eq: e }) => 161 + and( 162 + e(table.workspaceId, task.project.workspaceId), 163 + e(table.name, addedLabel.name), 164 + e(table.taskId, task.id), 165 + ), 166 + }); 167 + 168 + if (!existingLabel) { 169 + const color = addedLabel.color 170 + ? `#${addedLabel.color.replace(/^#/, "")}` 171 + : "#6B7280"; 172 + await db.insert(labelTable).values({ 173 + name: addedLabel.name, 174 + color, 175 + taskId: task.id, 176 + workspaceId: task.project.workspaceId, 177 + }); 178 + } 179 + } 180 + } 181 + 182 + if (payload.action === "unlabeled") { 183 + const labelsToDelete = await db.query.labelTable.findMany({ 184 + where: (table, { and, eq: e }) => 185 + and( 186 + e(table.taskId, existingLink.taskId), 187 + e(table.name, addedLabel.name), 188 + ), 189 + }); 190 + 191 + for (const label of labelsToDelete) { 192 + await db.delete(labelTable).where(eq(labelTable.id, label.id)); 193 + } 194 + } 195 + } 196 + }
+175
apps/api/src/plugins/gitea/webhooks/issue-opened.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import db from "../../../database"; 3 + import { columnTable, projectTable, taskTable } from "../../../database/schema"; 4 + import getNextTaskNumber from "../../../task/controllers/get-next-task-number"; 5 + import { 6 + createExternalLink, 7 + findExternalLink, 8 + } from "../../github/services/link-manager"; 9 + import { 10 + extractIssuePriority, 11 + extractIssueStatus, 12 + } from "../../github/utils/extract-priority"; 13 + import { formatTaskDescriptionFromIssue } from "../../github/utils/format"; 14 + import type { GiteaConfig } from "../config"; 15 + import { 16 + findAllIntegrationsByGiteaRepo, 17 + repoOwnerLogin, 18 + } from "../services/integration-lookup"; 19 + import { createGiteaClient } from "../utils/gitea-api"; 20 + import { addLabelsToIssueGitea } from "../utils/labels"; 21 + import { resolveTargetStatus } from "../utils/resolve-column"; 22 + import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo"; 23 + 24 + type IssueOpenedPayload = { 25 + action: string; 26 + issue: { 27 + number: number; 28 + title: string; 29 + body: string | null; 30 + html_url: string; 31 + labels?: Array<string | { name?: string }>; 32 + user: { login?: string; username?: string } | null; 33 + }; 34 + repository: { 35 + owner: { login?: string; username?: string }; 36 + name: string; 37 + html_url: string; 38 + }; 39 + }; 40 + 41 + export async function handleGiteaIssueOpened(payload: IssueOpenedPayload) { 42 + const { issue, repository } = payload; 43 + 44 + const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url); 45 + if (!baseUrl) { 46 + return; 47 + } 48 + 49 + const owner = repoOwnerLogin(repository); 50 + const integrations = await findAllIntegrationsByGiteaRepo( 51 + baseUrl, 52 + owner, 53 + repository.name, 54 + ); 55 + 56 + if (integrations.length === 0) { 57 + return; 58 + } 59 + 60 + for (const integration of integrations) { 61 + const config = JSON.parse(integration.config) as GiteaConfig; 62 + const projectId = integration.projectId; 63 + 64 + const priority = extractIssuePriority(issue.labels); 65 + const status = extractIssueStatus(issue.labels); 66 + 67 + const existingLink = await findExternalLink( 68 + integration.id, 69 + "issue", 70 + issue.number.toString(), 71 + ); 72 + 73 + if (existingLink) { 74 + continue; 75 + } 76 + 77 + const nextTaskNumber = await getNextTaskNumber(projectId); 78 + 79 + const resolvedStatus = await resolveTargetStatus( 80 + projectId, 81 + "issue_opened", 82 + status || "to-do", 83 + ); 84 + 85 + const targetColumn = await db.query.columnTable.findFirst({ 86 + where: and( 87 + eq(columnTable.projectId, projectId), 88 + eq(columnTable.slug, resolvedStatus), 89 + ), 90 + }); 91 + 92 + const taskValues: typeof taskTable.$inferInsert = { 93 + projectId, 94 + userId: null, 95 + title: issue.title, 96 + description: formatTaskDescriptionFromIssue(issue.body), 97 + status: resolvedStatus, 98 + columnId: targetColumn?.id ?? null, 99 + priority: null, 100 + number: nextTaskNumber + 1, 101 + }; 102 + 103 + if (priority) taskValues.priority = priority; 104 + 105 + const [createdTask] = await db 106 + .insert(taskTable) 107 + .values(taskValues) 108 + .returning(); 109 + 110 + if (!createdTask) { 111 + console.error("Failed to create task from Gitea issue"); 112 + continue; 113 + } 114 + 115 + await createExternalLink({ 116 + taskId: createdTask.id, 117 + integrationId: integration.id, 118 + resourceType: "issue", 119 + externalId: issue.number.toString(), 120 + url: issue.html_url, 121 + title: issue.title, 122 + metadata: { 123 + state: "open", 124 + createdFrom: "gitea", 125 + author: issue.user?.login ?? issue.user?.username, 126 + }, 127 + }); 128 + 129 + const project = await db.query.projectTable.findFirst({ 130 + where: eq(projectTable.id, projectId), 131 + }); 132 + 133 + if (!project) { 134 + continue; 135 + } 136 + 137 + const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173"; 138 + const taskUrl = `${clientUrl}/dashboard/workspace/${project.workspaceId}/project/${projectId}/task/${createdTask.id}`; 139 + const taskIdentifier = `${project.slug.toUpperCase()}-${createdTask.number}`; 140 + 141 + try { 142 + const client = createGiteaClient(config); 143 + 144 + const existingLabels = 145 + issue.labels 146 + ?.map((label) => (typeof label === "string" ? label : label.name)) 147 + .filter(Boolean) || []; 148 + 149 + const labelsToAdd: string[] = []; 150 + 151 + if (priority && !existingLabels.includes(`priority:${priority}`)) { 152 + labelsToAdd.push(`priority:${priority}`); 153 + } 154 + 155 + if (status && !existingLabels.includes(`status:${status}`)) { 156 + labelsToAdd.push(`status:${status}`); 157 + } 158 + 159 + if (labelsToAdd.length > 0) { 160 + await addLabelsToIssueGitea(config, issue.number, labelsToAdd); 161 + } 162 + 163 + if (config.commentTaskLinkOnGiteaIssue !== false) { 164 + await client.createIssueComment( 165 + config.repositoryOwner, 166 + config.repositoryName, 167 + issue.number, 168 + `[${taskIdentifier}](${taskUrl})`, 169 + ); 170 + } 171 + } catch (error) { 172 + console.error("Failed to process Gitea issue:", error); 173 + } 174 + } 175 + }
+76
apps/api/src/plugins/gitea/webhooks/label-created.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import db from "../../../database"; 3 + import { labelTable, projectTable } from "../../../database/schema"; 4 + import { 5 + findAllIntegrationsByGiteaRepo, 6 + repoOwnerLogin, 7 + } from "../services/integration-lookup"; 8 + import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo"; 9 + 10 + /** Gitea sends `create` events with ref_type `label` for new labels */ 11 + type LabelCreatePayload = { 12 + ref?: string; 13 + ref_type?: string; 14 + label?: { 15 + name: string; 16 + color: string; 17 + description?: string | null; 18 + }; 19 + repository: { 20 + owner: { login?: string; username?: string }; 21 + name: string; 22 + html_url: string; 23 + }; 24 + }; 25 + 26 + export async function handleGiteaLabelCreated(payload: LabelCreatePayload) { 27 + if (payload.ref_type !== "label" || !payload.label) { 28 + return; 29 + } 30 + 31 + const { repository, label } = payload; 32 + 33 + const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url); 34 + if (!baseUrl) return; 35 + 36 + const owner = repoOwnerLogin(repository); 37 + const integrations = await findAllIntegrationsByGiteaRepo( 38 + baseUrl, 39 + owner, 40 + repository.name, 41 + ); 42 + 43 + for (const integration of integrations) { 44 + if (!integration.project) { 45 + continue; 46 + } 47 + 48 + const project = await db.query.projectTable.findFirst({ 49 + where: eq(projectTable.id, integration.project.id), 50 + }); 51 + 52 + if (!project?.workspaceId) { 53 + continue; 54 + } 55 + 56 + const labelExists = await db.query.labelTable.findFirst({ 57 + where: (table, { and, eq: e }) => 58 + and( 59 + e(table.workspaceId, project.workspaceId), 60 + e(table.name, label.name), 61 + ), 62 + }); 63 + 64 + if (labelExists) { 65 + continue; 66 + } 67 + 68 + const color = label.color ? `#${label.color.replace(/^#/, "")}` : "#6B7280"; 69 + 70 + await db.insert(labelTable).values({ 71 + name: label.name, 72 + color, 73 + workspaceId: project.workspaceId, 74 + }); 75 + } 76 + }
+110
apps/api/src/plugins/gitea/webhooks/pull-request-closed.ts
··· 1 + import { and, eq } from "drizzle-orm"; 2 + import db from "../../../database"; 3 + import { externalLinkTable } from "../../../database/schema"; 4 + import { updateExternalLink } from "../../github/services/link-manager"; 5 + import { 6 + findTaskById, 7 + updateTaskStatus, 8 + } from "../../github/services/task-service"; 9 + import type { GiteaConfig } from "../config"; 10 + import { 11 + findAllIntegrationsByGiteaRepo, 12 + repoOwnerLogin, 13 + } from "../services/integration-lookup"; 14 + import { resolveTargetStatus } from "../utils/resolve-column"; 15 + import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo"; 16 + 17 + type PRClosedPayload = { 18 + action: string; 19 + pull_request: { 20 + number: number; 21 + title: string; 22 + html_url: string; 23 + state: string; 24 + merged: boolean; 25 + merged_at: string | null; 26 + head: { 27 + ref: string; 28 + }; 29 + }; 30 + repository: { 31 + owner: { login?: string; username?: string }; 32 + name: string; 33 + html_url: string; 34 + }; 35 + }; 36 + 37 + export async function handleGiteaPullRequestClosed(payload: PRClosedPayload) { 38 + const { pull_request, repository } = payload; 39 + 40 + const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url); 41 + if (!baseUrl) return; 42 + 43 + const owner = repoOwnerLogin(repository); 44 + const integrations = await findAllIntegrationsByGiteaRepo( 45 + baseUrl, 46 + owner, 47 + repository.name, 48 + ); 49 + 50 + for (const integration of integrations) { 51 + const config = JSON.parse(integration.config) as GiteaConfig; 52 + 53 + const externalLink = await db.query.externalLinkTable.findFirst({ 54 + where: and( 55 + eq(externalLinkTable.integrationId, integration.id), 56 + eq(externalLinkTable.resourceType, "pull_request"), 57 + eq(externalLinkTable.externalId, pull_request.number.toString()), 58 + ), 59 + }); 60 + 61 + if (!externalLink) { 62 + continue; 63 + } 64 + 65 + const task = await findTaskById(externalLink.taskId); 66 + 67 + if (!task) { 68 + continue; 69 + } 70 + 71 + const existingMetadata = externalLink.metadata 72 + ? JSON.parse(externalLink.metadata) 73 + : {}; 74 + 75 + await updateExternalLink(externalLink.id, { 76 + metadata: { 77 + ...existingMetadata, 78 + state: "closed", 79 + merged: pull_request.merged, 80 + mergedAt: pull_request.merged_at, 81 + }, 82 + }); 83 + 84 + if (pull_request.merged) { 85 + const allTaskPRs = await db.query.externalLinkTable.findMany({ 86 + where: and( 87 + eq(externalLinkTable.taskId, task.id), 88 + eq(externalLinkTable.resourceType, "pull_request"), 89 + ), 90 + }); 91 + 92 + const hasOpenPRs = allTaskPRs.some((pr) => { 93 + if (pr.id === externalLink.id) return false; 94 + const metadata = pr.metadata ? JSON.parse(pr.metadata) : {}; 95 + return metadata.state === "open"; 96 + }); 97 + 98 + if (!hasOpenPRs) { 99 + const targetStatus = await resolveTargetStatus( 100 + integration.projectId, 101 + "pr_merged", 102 + config.statusTransitions?.onPRMerge || "done", 103 + ); 104 + await updateTaskStatus(task.id, targetStatus); 105 + } 106 + } 107 + 108 + return; 109 + } 110 + }
+121
apps/api/src/plugins/gitea/webhooks/pull-request-opened.ts
··· 1 + import { 2 + createExternalLink, 3 + findExternalLink, 4 + } from "../../github/services/link-manager"; 5 + import { 6 + findTaskByNumber, 7 + isTaskInFinalState, 8 + updateTaskStatus, 9 + } from "../../github/services/task-service"; 10 + import type { GiteaConfig } from "../config"; 11 + import { 12 + findAllIntegrationsByGiteaRepo, 13 + repoOwnerLogin, 14 + } from "../services/integration-lookup"; 15 + import { extractTaskNumberGitea } from "../utils/branch-matcher"; 16 + import { resolveTargetStatus } from "../utils/resolve-column"; 17 + import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo"; 18 + 19 + type PROpenedPayload = { 20 + action: string; 21 + pull_request: { 22 + number: number; 23 + title: string; 24 + body: string | null; 25 + html_url: string; 26 + state: string; 27 + draft?: boolean; 28 + merged?: boolean; 29 + head: { 30 + ref: string; 31 + }; 32 + user: { login?: string; username?: string } | null; 33 + }; 34 + repository: { 35 + owner: { login?: string; username?: string }; 36 + name: string; 37 + html_url: string; 38 + }; 39 + }; 40 + 41 + export async function handleGiteaPullRequestOpened(payload: PROpenedPayload) { 42 + const { pull_request, repository } = payload; 43 + 44 + const baseUrl = baseUrlFromRepositoryHtmlUrl(repository.html_url); 45 + if (!baseUrl) return; 46 + 47 + const owner = repoOwnerLogin(repository); 48 + const integrations = await findAllIntegrationsByGiteaRepo( 49 + baseUrl, 50 + owner, 51 + repository.name, 52 + ); 53 + 54 + for (const integration of integrations) { 55 + if (!integration.project) { 56 + continue; 57 + } 58 + 59 + const config = JSON.parse(integration.config) as GiteaConfig; 60 + const projectSlug = integration.project.slug; 61 + const branchName = pull_request.head.ref; 62 + 63 + const taskNumber = extractTaskNumberGitea( 64 + branchName, 65 + pull_request.title, 66 + pull_request.body ?? undefined, 67 + config, 68 + projectSlug, 69 + ); 70 + 71 + if (!taskNumber) { 72 + continue; 73 + } 74 + 75 + const task = await findTaskByNumber(integration.projectId, taskNumber); 76 + 77 + if (!task) { 78 + continue; 79 + } 80 + 81 + const existingLink = await findExternalLink( 82 + integration.id, 83 + "pull_request", 84 + pull_request.number.toString(), 85 + ); 86 + 87 + if (existingLink) { 88 + continue; 89 + } 90 + 91 + await createExternalLink({ 92 + taskId: task.id, 93 + integrationId: integration.id, 94 + resourceType: "pull_request", 95 + externalId: pull_request.number.toString(), 96 + url: pull_request.html_url, 97 + title: pull_request.title, 98 + metadata: { 99 + state: pull_request.state, 100 + draft: pull_request.draft, 101 + merged: pull_request.merged, 102 + branch: branchName, 103 + author: pull_request.user?.login ?? pull_request.user?.username, 104 + }, 105 + }); 106 + 107 + const targetStatus = await resolveTargetStatus( 108 + integration.projectId, 109 + "pr_opened", 110 + config.statusTransitions?.onPROpen || "in-review", 111 + ); 112 + 113 + const isTaskFinal = await isTaskInFinalState(task); 114 + 115 + if (task.status !== targetStatus && !isTaskFinal) { 116 + await updateTaskStatus(task.id, targetStatus); 117 + } 118 + 119 + return; 120 + } 121 + }
+131
apps/api/src/plugins/gitea/webhooks/push.ts
··· 1 + import { createOrUpdateExternalLink } from "../../github/services/link-manager"; 2 + import { 3 + findTaskByNumber, 4 + isTaskInFinalState, 5 + updateTaskStatus, 6 + } from "../../github/services/task-service"; 7 + import type { GiteaConfig } from "../config"; 8 + import { 9 + findAllIntegrationsByGiteaRepo, 10 + repoOwnerLogin, 11 + } from "../services/integration-lookup"; 12 + import { extractTaskNumberFromBranchGitea } from "../utils/branch-matcher"; 13 + import { resolveTargetStatus } from "../utils/resolve-column"; 14 + 15 + type PushPayload = { 16 + ref: string; 17 + head_commit?: { 18 + id: string; 19 + message: string; 20 + author?: { name: string }; 21 + timestamp: string; 22 + }; 23 + commits?: Array<{ 24 + id: string; 25 + message: string; 26 + author?: { name: string; username?: string }; 27 + timestamp?: string; 28 + }>; 29 + repository: { 30 + owner: { login?: string; username?: string }; 31 + name: string; 32 + html_url: string; 33 + }; 34 + }; 35 + 36 + const PROTECTED_BRANCHES = [ 37 + "main", 38 + "master", 39 + "develop", 40 + "staging", 41 + "production", 42 + ]; 43 + 44 + export async function handleGiteaPush(payload: PushPayload) { 45 + const { ref, repository } = payload; 46 + 47 + const branchName = ref.replace("refs/heads/", ""); 48 + console.log(`[Gitea Push] Processing branch: ${branchName}`); 49 + 50 + if (PROTECTED_BRANCHES.includes(branchName)) { 51 + console.log(`[Gitea Push] Skipping protected branch: ${branchName}`); 52 + return; 53 + } 54 + 55 + const baseUrl = new URL(repository.html_url); 56 + const origin = `${baseUrl.protocol}//${baseUrl.host}`; 57 + const owner = repoOwnerLogin(repository); 58 + const integrations = await findAllIntegrationsByGiteaRepo( 59 + origin, 60 + owner, 61 + repository.name, 62 + ); 63 + 64 + if (integrations.length === 0) { 65 + return; 66 + } 67 + 68 + const headCommit = 69 + payload.head_commit ?? payload.commits?.[payload.commits.length - 1]; 70 + 71 + for (const integration of integrations) { 72 + if (!integration.project) { 73 + continue; 74 + } 75 + 76 + const config = JSON.parse(integration.config) as GiteaConfig; 77 + const projectSlug = integration.project.slug; 78 + 79 + const taskNumber = extractTaskNumberFromBranchGitea( 80 + branchName, 81 + config, 82 + projectSlug, 83 + ); 84 + 85 + if (!taskNumber) { 86 + continue; 87 + } 88 + 89 + const task = await findTaskByNumber(integration.projectId, taskNumber); 90 + 91 + if (!task) { 92 + continue; 93 + } 94 + 95 + const treeUrl = `${repository.html_url}/src/branch/${branchName}`; 96 + 97 + await createOrUpdateExternalLink({ 98 + taskId: task.id, 99 + integrationId: integration.id, 100 + resourceType: "branch", 101 + externalId: branchName, 102 + url: treeUrl, 103 + title: branchName, 104 + metadata: { 105 + lastCommit: headCommit 106 + ? { 107 + sha: headCommit.id, 108 + message: headCommit.message, 109 + author: headCommit.author?.name, 110 + timestamp: 111 + "timestamp" in headCommit ? headCommit.timestamp : undefined, 112 + } 113 + : null, 114 + }, 115 + }); 116 + 117 + const targetStatus = await resolveTargetStatus( 118 + integration.projectId, 119 + "branch_push", 120 + config.statusTransitions?.onBranchPush || "in-progress", 121 + ); 122 + 123 + const isTaskFinal = await isTaskInFinalState(task); 124 + 125 + if (task.status !== targetStatus && !isTaskFinal) { 126 + await updateTaskStatus(task.id, targetStatus); 127 + } 128 + 129 + return; 130 + } 131 + }
+2
apps/api/src/plugins/index.ts
··· 1 1 import { discordPlugin } from "./discord"; 2 2 import { genericWebhookPlugin } from "./generic-webhook"; 3 + import { giteaPlugin } from "./gitea"; 3 4 import { githubPlugin, initializeGitHubPlugin } from "./github"; 4 5 import { initializeEventSubscriptions, registerPlugin } from "./registry"; 5 6 import { slackPlugin } from "./slack"; ··· 8 9 console.log("Initializing plugins..."); 9 10 10 11 registerPlugin(githubPlugin); 12 + registerPlugin(giteaPlugin); 11 13 registerPlugin(slackPlugin); 12 14 registerPlugin(discordPlugin); 13 15 registerPlugin(genericWebhookPlugin);