import { Hono } from "hono"; import type { Context } from "hono"; import { initializeDatabase, createDeployment, updateDeploymentStatus, getDeployments, getDeploymentById, getCommandsByDeploymentId, type Deployment, type Command, } from "./db"; import { loadEnvConfig, verifySignature, execCommand, gitPullWithAuth, type EnvConfig, } from "./utils"; import { basename } from "node:path"; interface GiteaWebhookPayload { repository?: { name?: string; owner?: { login?: string; }; full_name?: string; }; ref?: string; commits?: Array<{ id?: string; message?: string; }>; } // Deploy function signature type DeployFunction = ( branch?: string, commitHash?: string, existingDeploymentId?: number ) => Promise<{ success: boolean; message: string; deploymentId?: number }>; // Repository configuration - maps repo to its deploy function interface RepoEntry { repoName: string; organization: string; deploy: DeployFunction; } // Load environment configuration at startup const env: EnvConfig = loadEnvConfig(); // Initialize database initializeDatabase(process.env.DB_PATH); // ============================================================================ // Deploy Functions for Each Repository // ============================================================================ interface RepoMetadata { repoPath: string; organization: string; repoName: string; } function resolveRepoMetadata(localPath: string, label: string): RepoMetadata { const proc = Bun.spawnSync(["git", "config", "--get", "remote.origin.url"], { cwd: localPath, stdout: "pipe", stderr: "pipe", }); const decoder = new TextDecoder(); const remoteUrl = decoder.decode(proc.stdout).trim(); if (proc.exitCode !== 0 || !remoteUrl) { console.error(`❌ Unable to resolve remote.origin.url for ${label} at ${localPath}`); console.error(decoder.decode(proc.stderr).trim() || "No git remote configured"); process.exit(1); } const repoPath = normalizeRepoPath(remoteUrl); const { organization, repoName } = extractOrganizationAndRepo(repoPath, localPath); return { repoPath, organization, repoName }; } function normalizeRepoPath(remoteUrl: string): string { if (remoteUrl.startsWith("http://") || remoteUrl.startsWith("https://")) { const url = new URL(remoteUrl); return `${url.host}${url.pathname}`.replace(/^\/+/, ""); } const sshMatch = remoteUrl.match(/^(?:.+@)?([^:]+):(.+)$/); if (sshMatch) { return `${sshMatch[1]}/${sshMatch[2]}`.replace(/^\/+/, ""); } return remoteUrl.replace(/^\/+/, ""); } function extractOrganizationAndRepo(repoPath: string, localPath: string): { organization: string; repoName: string } { const cleaned = repoPath.replace(/\.git$/, ""); const parts = cleaned.split("/").filter(Boolean); const organization = parts.at(-2); const repoName = parts.at(-1); if (organization && repoName) { return { organization, repoName }; } return { organization: "", repoName: basename(localPath) }; } function createPullOnlyDeploy( label: string, repoPath: string, localPath: string ): DeployFunction { return async ( branch?: string, commitHash?: string, existingDeploymentId?: number ): Promise<{ success: boolean; message: string; deploymentId?: number }> => { const repository = repoPath.replace(/\.git$/, ""); const deploymentId = existingDeploymentId ?? createDeployment(repository, branch, commitHash, "in_progress"); if (existingDeploymentId) updateDeploymentStatus(deploymentId, "in_progress"); console.log(`Starting deployment for ${label} (Deployment ID: ${deploymentId})...`); try { const gitResult = await gitPullWithAuth( repoPath, localPath, deploymentId, env.productionBranch, env.giteaUsername, env.giteaPassword ); if (!gitResult.success) { updateDeploymentStatus(deploymentId, "failed"); return { success: false, message: gitResult.error!, deploymentId }; } console.log(`✅ Deployment successful`); updateDeploymentStatus(deploymentId, "success"); return { success: true, message: `Successfully updated ${label}`, deploymentId }; } catch (error) { updateDeploymentStatus(deploymentId, "failed"); return { success: false, message: `Deployment failed: ${error instanceof Error ? error.message : String(error)}`, deploymentId }; } }; } // ============================================================================ // Repository Configuration // ============================================================================ const adminRepo = resolveRepoMetadata(env.yimaruAdminPath, "Yimaru Admin"); const backendRepo = resolveRepoMetadata(env.yimaruBackendPath, "Yimaru Backend"); const REPO_CONFIGS: RepoEntry[] = [ { repoName: adminRepo.repoName, organization: adminRepo.organization, deploy: createPullOnlyDeploy("Yimaru Admin", adminRepo.repoPath, env.yimaruAdminPath), }, { repoName: backendRepo.repoName, organization: backendRepo.organization, deploy: createPullOnlyDeploy("Yimaru Backend", backendRepo.repoPath, env.yimaruBackendPath), }, ]; /** * Find repository entry by name */ function findRepoEntry(repoName: string, organization: string): RepoEntry | undefined { return REPO_CONFIGS.find( (entry) => entry.repoName === repoName && (entry.organization.length === 0 || entry.organization === organization) ); } // ============================================================================ // HTTP Handlers // ============================================================================ /** * Handle webhook request */ async function handleWebhook(c: Context): Promise { try { // Get signature and auth header from headers const signature = c.req.header("X-Gitea-Signature") || ""; const authHeader = c.req.header("Authorization") || c.req.header("X-Gitea-Auth") || ""; const payload = await c.req.text(); // Verify auth header if (authHeader !== env.webhookAuthHeader) { console.error("Invalid webhook auth header"); return c.json({ error: "Invalid authorization" }, 401); } // Verify signature if (!verifySignature(payload, signature, env.webhookSecret)) { console.error("Invalid webhook signature"); return c.json({ error: "Invalid signature" }, 401); } // Parse payload const data: GiteaWebhookPayload = JSON.parse(payload); // Extract repository information const repoName = data.repository?.name; const organization = data.repository?.owner?.login || data.repository?.full_name?.split("/")[0]; const ref = data.ref || ""; const commitHash = data.commits?.[0]?.id || ""; if (!repoName || !organization) { return c.json({ error: "Invalid webhook payload: missing repository information" }, 400); } // Extract branch name from ref (e.g., "refs/heads/main" -> "main") const branch = ref.replace("refs/heads/", "") || undefined; // Only process pushes to the production branch if (ref !== `refs/heads/${env.productionBranch}`) { console.log(`⚠️ Ignoring push to non-production branch: ${ref}`); return c.json({ message: `Ignoring push to ${ref}. Only the production branch (${env.productionBranch}) triggers deployments.`, }, 200); } console.log(`Received webhook for ${organization}/${repoName} on ${ref} (commit: ${commitHash})`); // Find repository entry const entry = findRepoEntry(repoName, organization); if (!entry) { console.warn(`No configuration found for ${organization}/${repoName}`); return c.json({ message: `Repository ${organization}/${repoName} not configured` }, 200); } // Create deployment record immediately const deploymentId = createDeployment( `${entry.organization}/${entry.repoName}`, branch, commitHash, "pending" ); // Run deployment in background (don't await) entry.deploy(branch, commitHash, deploymentId) .then((result) => { if (result.success) { console.log(`✅ Deployment ${deploymentId} completed: ${result.message}`); } else { console.error(`❌ Deployment ${deploymentId} failed: ${result.message}`); } }) .catch((error) => { console.error(`❌ Deployment ${deploymentId} error:`, error); updateDeploymentStatus(deploymentId, "failed"); }); // Immediately respond to webhook return c.json({ message: "Deployment started", deploymentId, repository: `${entry.organization}/${entry.repoName}`, branch, }, 202); } catch (error) { console.error("Error processing webhook:", error); return c.json({ error: "Internal server error", message: error instanceof Error ? error.message : String(error), }, 500); } } function handleHealthCheck(c: Context): Response { return c.json({ status: "ok", timestamp: new Date().toISOString(), repositories: REPO_CONFIGS.map((entry) => ({ repo: `${entry.organization}/${entry.repoName}`, })), }); } function handleGetDeployments(c: Context): Response { const limit = parseInt(c.req.query("limit") || "50", 10); const repository = c.req.query("repository"); const deployments = getDeployments(limit, repository); return c.json({ deployments }); } function handleGetDeployment(c: Context): Response { const deploymentId = parseInt(c.req.param("id"), 10); if (isNaN(deploymentId)) { return c.json({ error: "Invalid deployment ID" }, 400); } const deployment = getDeploymentById(deploymentId); if (!deployment) { return c.json({ error: "Deployment not found" }, 404); } const commands = getCommandsByDeploymentId(deploymentId); return c.json({ deployment, commands }); } // ============================================================================ // Hono App Setup // ============================================================================ const app = new Hono(); // HTML Pages app.get("/", async (c: Context) => { const limit = parseInt(c.req.query("limit") || "50", 10); const repository = c.req.query("repository"); const deployments = getDeployments(limit, repository); const { DeploymentsList } = await import("./pages/DeploymentsList"); const html = DeploymentsList({ deployments }); return c.html(html); }); app.get("/deployments/:id", async (c: Context) => { const deploymentId = parseInt(c.req.param("id"), 10); if (isNaN(deploymentId)) { return c.html("

Invalid deployment ID

", 400); } const deployment = getDeploymentById(deploymentId); if (!deployment) { return c.html("

Deployment not found

", 404); } const commands = getCommandsByDeploymentId(deploymentId); const { DeploymentDetail } = await import("./pages/DeploymentDetail"); const html = DeploymentDetail({ deployment, commands }); return c.html(html); }); // Health check endpoint app.get("/health", handleHealthCheck); // API endpoints (JSON) app.get("/api/deployments", handleGetDeployments); app.get("/api/deployments/:id", handleGetDeployment); // Webhook endpoints app.post("/webhook", handleWebhook); app.post("/webhook/gitea", handleWebhook); // 404 handler app.notFound((c: Context) => { return c.json({ error: "Not found" }, 404); }); // Start server const server = Bun.serve({ port: env.port, fetch: app.fetch, }); console.log(`🚀 Gitea Webhook CD Server running on http://localhost:${server.port}`); console.log(`📡 Webhook endpoint: http://localhost:${server.port}/webhook`); console.log(`❤️ Health check: http://localhost:${server.port}/health`); console.log(`📊 Deployments API: http://localhost:${server.port}/deployments`); console.log(`💾 Database: ${process.env.DB_PATH || "deployments.db"}`);