import { existsSync } from "node:fs"; import path from "node:path"; import { Elysia, t } from "elysia"; import { CI_RUNS_PER_PAGE, paths } from "../constants.ts"; import { db, getRepo } from "../db/index.ts"; import { contentDisposition } from "../lib/contentDisposition.ts"; import { paginate } from "../lib/pagination.ts"; import { requireAdmin, resolveSession } from "../middleware/session.ts"; import { cancelRun, ciQueuePosition, parseCiConfig, purgeRepoCaches, retryRun, triggerRun, } from "../services/ci.ts"; import { git } from "../services/git.ts"; import { CiHistory } from "../views/ci/CiHistory.tsx"; import { CiRunDetail } from "../views/ci/CiRunDetail.tsx"; import { html } from "../views/render.tsx"; /** Generate an SVG badge for CI status */ function makeBadge(status: string): string { const colors: Record = { success: "#4c1", warning: "#dfb317", failure: "#e05d44", running: "#007ec6", pending: "#9f9f9f", cancelled: "#9f9f9f", }; const color = colors[status] ?? "#9f9f9f"; const label = "pipeline"; const value = status; const labelWidth = label.length * 6 + 10; const valueWidth = value.length * 6 + 10; const totalWidth = labelWidth + valueWidth; return ` ${label} ${label} ${value} ${value} `; } export const ciRoutes = new Elysia() .guard({ cookie: t.Cookie({ session: t.Optional(t.String()) }), }) // Badge — no auth required for public repos .get("/:repo/ci/badge.svg", async ({ params }) => { const repo = await db .selectFrom("repositories") .select(["id", "is_private"]) .where("name", "=", params.repo) .executeTakeFirst(); if (!repo || repo.is_private) { return new Response("Not found", { status: 404 }); } const latestRun = await db .selectFrom("ci_runs") .select("status") .where("repo_id", "=", repo.id) .orderBy("id", "desc") .limit(1) .executeTakeFirst(); const status = latestRun?.status ?? "no builds"; return new Response(makeBadge(status), { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "no-cache", }, }); }) // Run history .get( "/:repo/ci", async ({ params, query, cookie }) => { const user = await resolveSession(cookie.session.value); const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const countRow = await db .selectFrom("ci_runs") .select(db.fn.countAll().as("count")) .where("repo_id", "=", repo.id) .executeTakeFirst(); const { page: safePage, totalPages, offset, } = paginate( query.page, Number(countRow?.count ?? 0), CI_RUNS_PER_PAGE, ); const runs = await db .selectFrom("ci_runs") .leftJoin("users", "users.id", "ci_runs.triggered_by") .select([ "ci_runs.id", "ci_runs.repo_run_id", "ci_runs.status", "ci_runs.trigger_source", "ci_runs.commit_sha", "ci_runs.commit_branch", "ci_runs.commit_tag", "ci_runs.started_at", "ci_runs.finished_at", "ci_runs.created_at", "users.username as triggered_by_username", ]) .where("repo_id", "=", repo.id) .orderBy("ci_runs.id", "desc") .limit(CI_RUNS_PER_PAGE) .offset(offset) .execute(); // Artifact counts per run const runIds = runs.map((r) => r.id); const artifactCounts = runIds.length > 0 ? await db .selectFrom("ci_artifacts") .select([ "run_id", db.fn.countAll().as("count"), ]) .where("run_id", "in", runIds) .groupBy("run_id") .execute() : []; const artifactCountMap = new Map( artifactCounts.map((r) => [r.run_id, Number(r.count)]), ); const runsWithCounts = runs.map((r) => ({ ...r, artifact_count: artifactCountMap.get(r.id) ?? 0, queue_position: r.status === "queued" ? ciQueuePosition(r.id) : null, })); // Determine why manual trigger may be unavailable (admin-only check) let manualTriggerDisabledReason: string | null = null; if (user?.isAdmin) { const branches = await git.branches(repo.name); const defaultBranch = repo.default_branch || branches[0]; if (!defaultBranch) { manualTriggerDisabledReason = "No branches — push a commit first"; } else { const headLog = await git.log(repo.name, defaultBranch, 1); if (!headLog.length) { manualTriggerDisabledReason = "No commits yet"; } else { const tomlBuf = await git.show( repo.name, headLog[0]!.hash, ".hearthforge-ci.toml", ); if (!tomlBuf) { manualTriggerDisabledReason = "No .hearthforge-ci.toml found in repository"; } else { const cfg = parseCiConfig( tomlBuf.toString("utf-8"), ); if (!cfg) { manualTriggerDisabledReason = "Failed to parse .hearthforge-ci.toml"; } } } } } return html( , ); }, { query: t.Object({ page: t.Optional(t.Number()) }) }, ) // Run detail .get( "/:repo/ci/:runId", async ({ params, query, cookie }) => { const user = await resolveSession(cookie.session.value); const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const runId = Number(params.runId); const run = await db .selectFrom("ci_runs") .leftJoin("users", "users.id", "ci_runs.triggered_by") .select([ "ci_runs.id", "ci_runs.repo_run_id", "ci_runs.status", "ci_runs.trigger_source", "ci_runs.commit_sha", "ci_runs.commit_branch", "ci_runs.commit_tag", "ci_runs.variable_overrides", "ci_runs.started_at", "ci_runs.finished_at", "ci_runs.created_at", "users.username as triggered_by_username", ]) .where("ci_runs.id", "=", runId) .where("ci_runs.repo_id", "=", repo.id) .executeTakeFirst(); if (!run) return new Response("Not found", { status: 404 }); const steps = await db .selectFrom("ci_steps") .selectAll() .where("run_id", "=", runId) .orderBy("id", "asc") .execute(); const artifacts = await db .selectFrom("ci_artifacts") .selectAll() .where("run_id", "=", runId) .orderBy("id", "asc") .execute(); return html( , ); }, { query: t.Object({ refresh: t.Optional(t.String()) }) }, ) // Manual trigger .post("/:repo/ci/run", async ({ params, body, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await getRepo(params.repo, true); if (!repo) return new Response("Not found", { status: 404 }); // Read CI config at HEAD to check manual trigger is allowed and get variable definitions const branches = await git.branches(repo.name); const defaultBranch = repo.default_branch || branches[0]; if (!defaultBranch) return new Response("No branches", { status: 400 }); const headLog = await git.log(repo.name, defaultBranch, 1); if (!headLog.length) return new Response("No commits", { status: 400 }); const headSha = headLog[0]!.hash; const tomlBuf = await git.show( repo.name, headSha, ".hearthforge-ci.toml", ); if (!tomlBuf) return new Response( "No .hearthforge-ci.toml found at HEAD. Add one to your repository to use CI pipelines.", { status: 400 }, ); const cfg = parseCiConfig(tomlBuf.toString("utf-8")); if (!cfg) return new Response( "Failed to parse .hearthforge-ci.toml. Check the file for syntax errors.", { status: 400 }, ); // Parse variable overrides from form body const variableOverrides: Record = {}; if (cfg.variables) { for (const varName of Object.keys(cfg.variables)) { const formKey = `var_${varName}`; const val = (body as Record)[formKey]; if (typeof val === "string") { variableOverrides[varName] = val; } } } const runId = await triggerRun(repo.name, { triggerSource: "manual", commitSha: headSha, commitBranch: defaultBranch, triggeredBy: user!.id, variableOverrides, }); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/ci/${runId}` }, }); }) // Retry .post("/:repo/ci/:runId/retry", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await getRepo(params.repo, true); if (!repo) return new Response("Not found", { status: 404 }); const runId = Number(params.runId); const existing = await db .selectFrom("ci_runs") .select("id") .where("id", "=", runId) .where("repo_id", "=", repo.id) .executeTakeFirst(); if (!existing) return new Response("Not found", { status: 404 }); await retryRun(runId, user!.id); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/ci/${runId}` }, }); }) // Cancel .post("/:repo/ci/:runId/cancel", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await getRepo(params.repo, true); if (!repo) return new Response("Not found", { status: 404 }); const runId = Number(params.runId); const run = await db .selectFrom("ci_runs") .select("id") .where("id", "=", runId) .where("repo_id", "=", repo.id) .executeTakeFirst(); if (!run) return new Response("Not found", { status: 404 }); await cancelRun(runId); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/ci/${runId}` }, }); }) // Purge cache volumes .post("/:repo/ci/purge-cache", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await getRepo(params.repo, true); if (!repo) return new Response("Not found", { status: 404 }); await purgeRepoCaches(repo.name); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/ci?success=Cache+purged.`, }, }); }) // Create secret .post("/:repo/settings/ci-secrets", async ({ params, body, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await db .selectFrom("repositories") .select("id") .where("name", "=", params.repo) .executeTakeFirst(); if (!repo) return new Response("Not found", { status: 404 }); const name = (body as Record).name?.trim(); const value = (body as Record).value; const description = (body as Record).description?.trim() || null; if (!name || !/^[A-Z_][A-Z0-9_]*$/i.test(name)) { return new Response(null, { status: 302, headers: { Location: `/${params.repo}/settings?error=${encodeURIComponent("Secret name must be a valid identifier.")}`, }, }); } if (!value) { return new Response(null, { status: 302, headers: { Location: `/${params.repo}/settings?error=${encodeURIComponent("Secret value cannot be empty.")}`, }, }); } await db .insertInto("ci_secrets") .values({ repo_id: repo.id, name, value, description, }) .onConflict((oc) => oc .columns(["repo_id", "name"]) .doUpdateSet({ value, description }), ) .execute(); return new Response(null, { status: 302, headers: { Location: `/${params.repo}/settings?success=Secret+saved.`, }, }); }) // Delete secret .post( "/:repo/settings/ci-secrets/delete", async ({ params, body, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await db .selectFrom("repositories") .select("id") .where("name", "=", params.repo) .executeTakeFirst(); if (!repo) return new Response("Not found", { status: 404 }); const id = Number((body as Record).id); await db .deleteFrom("ci_secrets") .where("id", "=", id) .where("repo_id", "=", repo.id) .execute(); return new Response(null, { status: 302, headers: { Location: `/${params.repo}/settings?success=Secret+deleted.`, }, }); }, ) // Artifact download .get( "/:repo/ci/:runId/artifacts/:artifactId", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const runId = Number(params.runId); const artifactId = Number(params.artifactId); const artifact = await db .selectFrom("ci_artifacts") .innerJoin("ci_runs", "ci_runs.id", "ci_artifacts.run_id") .select([ "ci_artifacts.id", "ci_artifacts.filename", "ci_artifacts.size", ]) .where("ci_artifacts.id", "=", artifactId) .where("ci_runs.id", "=", runId) .where("ci_runs.repo_id", "=", repo.id) .executeTakeFirst(); if (!artifact) return new Response("Not found", { status: 404 }); const filePath = path.join( paths.CI_ARTIFACTS_DIR, String(runId), artifact.filename, ); if (!existsSync(filePath)) return new Response("File not found", { status: 404 }); return new Response(Bun.file(filePath), { headers: { "Content-Disposition": contentDisposition( "attachment", artifact.filename, ), "Content-Type": "application/octet-stream", "Content-Length": String(artifact.size), }, }); }, );