ci.tsx
Raw
1import { existsSync } from "node:fs";
2import path from "node:path";
3import { Elysia, t } from "elysia";
4import { CI_RUNS_PER_PAGE, paths } from "../constants.ts";
5import { db, getRepo } from "../db/index.ts";
6import { requireAdmin, resolveSession } from "../middleware/session.ts";
7import {
8 cancelRun,
9 parseCiConfig,
10 purgeRepoCaches,
11 retryRun,
12 triggerRun,
13} from "../services/ci.ts";
14import { git } from "../services/git.ts";
15import { CiHistory } from "../views/ci/CiHistory.tsx";
16import { CiRunDetail } from "../views/ci/CiRunDetail.tsx";
17import { html } from "../views/render.tsx";
18
19/** Generate an SVG badge for CI status */
20function makeBadge(status: string): string {
21 const colors: Record<string, string> = {
22 success: "#4c1",
23 failure: "#e05d44",
24 running: "#007ec6",
25 pending: "#9f9f9f",
26 cancelled: "#9f9f9f",
27 };
28 const color = colors[status] ?? "#9f9f9f";
29 const label = "pipeline";
30 const value = status;
31 const labelWidth = label.length * 6 + 10;
32 const valueWidth = value.length * 6 + 10;
33 const totalWidth = labelWidth + valueWidth;
34 return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20">
35 <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
36 <clipPath id="r"><rect width="${totalWidth}" height="20" rx="3"/></clipPath>
37 <g clip-path="url(#r)">
38 <rect width="${labelWidth}" height="20" fill="#555"/>
39 <rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color}"/>
40 <rect width="${totalWidth}" height="20" fill="url(#s)"/>
41 </g>
42 <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
43 <text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${label}</text>
44 <text x="${labelWidth / 2}" y="14">${label}</text>
45 <text x="${labelWidth + valueWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${value}</text>
46 <text x="${labelWidth + valueWidth / 2}" y="14">${value}</text>
47 </g>
48</svg>`;
49}
50
51export const ciRoutes = new Elysia()
52 .guard({
53 cookie: t.Cookie({ session: t.Optional(t.String()) }),
54 })
55
56 // Badge — no auth required for public repos
57 .get("/:repo/ci/badge.svg", async ({ params }) => {
58 const repo = await db
59 .selectFrom("repositories")
60 .select(["id", "is_private"])
61 .where("name", "=", params.repo)
62 .executeTakeFirst();
63 if (!repo || repo.is_private) {
64 return new Response("Not found", { status: 404 });
65 }
66 const latestRun = await db
67 .selectFrom("ci_runs")
68 .select("status")
69 .where("repo_id", "=", repo.id)
70 .orderBy("id", "desc")
71 .limit(1)
72 .executeTakeFirst();
73 const status = latestRun?.status ?? "no builds";
74 return new Response(makeBadge(status), {
75 headers: {
76 "Content-Type": "image/svg+xml",
77 "Cache-Control": "no-cache",
78 },
79 });
80 })
81
82 // Run history
83 .get(
84 "/:repo/ci",
85 async ({ params, query, cookie }) => {
86 const user = await resolveSession(cookie.session.value);
87 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
88 if (!repo) return new Response("Not found", { status: 404 });
89
90 const page = Math.max(1, query.page ?? 1);
91
92 const countRow = await db
93 .selectFrom("ci_runs")
94 .select(db.fn.countAll<number>().as("count"))
95 .where("repo_id", "=", repo.id)
96 .executeTakeFirst();
97 const totalPages = Math.max(
98 1,
99 Math.ceil(Number(countRow?.count ?? 0) / CI_RUNS_PER_PAGE),
100 );
101 const safePage = Math.min(page, totalPages);
102 const offset = (safePage - 1) * CI_RUNS_PER_PAGE;
103
104 const runs = await db
105 .selectFrom("ci_runs")
106 .leftJoin("users", "users.id", "ci_runs.triggered_by")
107 .select([
108 "ci_runs.id",
109 "ci_runs.repo_run_id",
110 "ci_runs.status",
111 "ci_runs.trigger_source",
112 "ci_runs.commit_sha",
113 "ci_runs.commit_branch",
114 "ci_runs.commit_tag",
115 "ci_runs.started_at",
116 "ci_runs.finished_at",
117 "ci_runs.created_at",
118 "users.username as triggered_by_username",
119 ])
120 .where("repo_id", "=", repo.id)
121 .orderBy("ci_runs.id", "desc")
122 .limit(CI_RUNS_PER_PAGE)
123 .offset(offset)
124 .execute();
125
126 // Artifact counts per run
127 const runIds = runs.map((r) => r.id);
128 const artifactCounts =
129 runIds.length > 0
130 ? await db
131 .selectFrom("ci_artifacts")
132 .select([
133 "run_id",
134 db.fn.countAll<number>().as("count"),
135 ])
136 .where("run_id", "in", runIds)
137 .groupBy("run_id")
138 .execute()
139 : [];
140 const artifactCountMap = new Map(
141 artifactCounts.map((r) => [r.run_id, Number(r.count)]),
142 );
143
144 const runsWithCounts = runs.map((r) => ({
145 ...r,
146 artifact_count: artifactCountMap.get(r.id) ?? 0,
147 }));
148
149 // Determine why manual trigger may be unavailable (admin-only check)
150 let manualTriggerDisabledReason: string | null = null;
151 if (user?.isAdmin) {
152 const branches = await git.branches(repo.name);
153 const defaultBranch = repo.default_branch || branches[0];
154 if (!defaultBranch) {
155 manualTriggerDisabledReason =
156 "No branches — push a commit first";
157 } else {
158 const headLog = await git.log(repo.name, defaultBranch, 1);
159 if (!headLog.length) {
160 manualTriggerDisabledReason = "No commits yet";
161 } else {
162 const tomlBuf = await git.show(
163 repo.name,
164 headLog[0]!.hash,
165 ".hearthforge-ci.toml",
166 );
167 if (!tomlBuf) {
168 manualTriggerDisabledReason =
169 "No .hearthforge-ci.toml found in repository";
170 } else {
171 const cfg = parseCiConfig(
172 tomlBuf.toString("utf-8"),
173 );
174 if (!cfg) {
175 manualTriggerDisabledReason =
176 "Failed to parse .hearthforge-ci.toml";
177 }
178 }
179 }
180 }
181 }
182
183 return html(
184 <CiHistory
185 user={user}
186 repo={repo}
187 runs={runsWithCounts}
188 pagination={{
189 page: safePage,
190 totalPages,
191 pageUrlTemplate: `/${repo.name}/ci?page={page}`,
192 }}
193 manualTriggerDisabledReason={manualTriggerDisabledReason}
194 />,
195 );
196 },
197 { query: t.Object({ page: t.Optional(t.Number()) }) },
198 )
199
200 // Run detail
201 .get(
202 "/:repo/ci/:runId",
203 async ({ params, query, cookie }) => {
204 const user = await resolveSession(cookie.session.value);
205 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
206 if (!repo) return new Response("Not found", { status: 404 });
207
208 const runId = Number(params.runId);
209 const run = await db
210 .selectFrom("ci_runs")
211 .leftJoin("users", "users.id", "ci_runs.triggered_by")
212 .select([
213 "ci_runs.id",
214 "ci_runs.repo_run_id",
215 "ci_runs.status",
216 "ci_runs.trigger_source",
217 "ci_runs.commit_sha",
218 "ci_runs.commit_branch",
219 "ci_runs.commit_tag",
220 "ci_runs.variable_overrides",
221 "ci_runs.started_at",
222 "ci_runs.finished_at",
223 "ci_runs.created_at",
224 "users.username as triggered_by_username",
225 ])
226 .where("ci_runs.id", "=", runId)
227 .where("ci_runs.repo_id", "=", repo.id)
228 .executeTakeFirst();
229 if (!run) return new Response("Not found", { status: 404 });
230
231 const steps = await db
232 .selectFrom("ci_steps")
233 .selectAll()
234 .where("run_id", "=", runId)
235 .orderBy("id", "asc")
236 .execute();
237
238 const artifacts = await db
239 .selectFrom("ci_artifacts")
240 .selectAll()
241 .where("run_id", "=", runId)
242 .orderBy("id", "asc")
243 .execute();
244
245 return html(
246 <CiRunDetail
247 user={user}
248 repo={repo}
249 run={run}
250 steps={steps}
251 artifacts={artifacts}
252 autoRefresh={query.refresh !== "off"}
253 />,
254 );
255 },
256 { query: t.Object({ refresh: t.Optional(t.String()) }) },
257 )
258
259 // Manual trigger
260 .post("/:repo/ci/run", async ({ params, body, cookie }) => {
261 const user = await resolveSession(cookie.session.value);
262 const deny = requireAdmin(user);
263 if (deny) return deny;
264 const repo = await getRepo(params.repo, true);
265 if (!repo) return new Response("Not found", { status: 404 });
266
267 // Read CI config at HEAD to check manual trigger is allowed and get variable definitions
268 const branches = await git.branches(repo.name);
269 const defaultBranch = repo.default_branch || branches[0];
270 if (!defaultBranch) return new Response("No branches", { status: 400 });
271
272 const headLog = await git.log(repo.name, defaultBranch, 1);
273 if (!headLog.length) return new Response("No commits", { status: 400 });
274 const headSha = headLog[0]!.hash;
275
276 const tomlBuf = await git.show(
277 repo.name,
278 headSha,
279 ".hearthforge-ci.toml",
280 );
281 if (!tomlBuf)
282 return new Response(
283 "No .hearthforge-ci.toml found at HEAD. Add one to your repository to use CI pipelines.",
284 { status: 400 },
285 );
286 const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
287 if (!cfg)
288 return new Response(
289 "Failed to parse .hearthforge-ci.toml. Check the file for syntax errors.",
290 { status: 400 },
291 );
292
293 // Parse variable overrides from form body
294 const variableOverrides: Record<string, string> = {};
295 if (cfg.variables) {
296 for (const varName of Object.keys(cfg.variables)) {
297 const formKey = `var_${varName}`;
298 const val = (body as Record<string, string>)[formKey];
299 if (typeof val === "string") {
300 variableOverrides[varName] = val;
301 }
302 }
303 }
304
305 const runId = await triggerRun(repo.name, {
306 triggerSource: "manual",
307 commitSha: headSha,
308 commitBranch: defaultBranch,
309 triggeredBy: user!.id,
310 variableOverrides,
311 });
312
313 return new Response(null, {
314 status: 302,
315 headers: { Location: `/${repo.name}/ci/${runId}` },
316 });
317 })
318
319 // Retry
320 .post("/:repo/ci/:runId/retry", async ({ params, cookie }) => {
321 const user = await resolveSession(cookie.session.value);
322 const deny = requireAdmin(user);
323 if (deny) return deny;
324 const repo = await getRepo(params.repo, true);
325 if (!repo) return new Response("Not found", { status: 404 });
326
327 const runId = Number(params.runId);
328 const existing = await db
329 .selectFrom("ci_runs")
330 .select("id")
331 .where("id", "=", runId)
332 .where("repo_id", "=", repo.id)
333 .executeTakeFirst();
334 if (!existing) return new Response("Not found", { status: 404 });
335
336 await retryRun(runId, user!.id);
337
338 return new Response(null, {
339 status: 302,
340 headers: { Location: `/${repo.name}/ci/${runId}` },
341 });
342 })
343
344 // Cancel
345 .post("/:repo/ci/:runId/cancel", async ({ params, cookie }) => {
346 const user = await resolveSession(cookie.session.value);
347 const deny = requireAdmin(user);
348 if (deny) return deny;
349 const repo = await getRepo(params.repo, true);
350 if (!repo) return new Response("Not found", { status: 404 });
351
352 const runId = Number(params.runId);
353 const run = await db
354 .selectFrom("ci_runs")
355 .select("id")
356 .where("id", "=", runId)
357 .where("repo_id", "=", repo.id)
358 .executeTakeFirst();
359 if (!run) return new Response("Not found", { status: 404 });
360
361 await cancelRun(runId);
362
363 return new Response(null, {
364 status: 302,
365 headers: { Location: `/${repo.name}/ci/${runId}` },
366 });
367 })
368
369 // Purge cache volumes
370 .post("/:repo/ci/purge-cache", async ({ params, cookie }) => {
371 const user = await resolveSession(cookie.session.value);
372 const deny = requireAdmin(user);
373 if (deny) return deny;
374 const repo = await getRepo(params.repo, true);
375 if (!repo) return new Response("Not found", { status: 404 });
376
377 await purgeRepoCaches(repo.name);
378
379 return new Response(null, {
380 status: 302,
381 headers: {
382 Location: `/${repo.name}/ci?success=Cache+purged.`,
383 },
384 });
385 })
386
387 // Create secret
388 .post("/:repo/settings/ci-secrets", async ({ params, body, cookie }) => {
389 const user = await resolveSession(cookie.session.value);
390 const deny = requireAdmin(user);
391 if (deny) return deny;
392 const repo = await db
393 .selectFrom("repositories")
394 .select("id")
395 .where("name", "=", params.repo)
396 .executeTakeFirst();
397 if (!repo) return new Response("Not found", { status: 404 });
398
399 const name = (body as Record<string, string>).name?.trim();
400 const value = (body as Record<string, string>).value;
401 const description =
402 (body as Record<string, string>).description?.trim() || null;
403
404 if (!name || !/^[A-Z_][A-Z0-9_]*$/i.test(name)) {
405 return new Response(null, {
406 status: 302,
407 headers: {
408 Location: `/${params.repo}/settings?error=${encodeURIComponent("Secret name must be a valid identifier.")}`,
409 },
410 });
411 }
412 if (!value) {
413 return new Response(null, {
414 status: 302,
415 headers: {
416 Location: `/${params.repo}/settings?error=${encodeURIComponent("Secret value cannot be empty.")}`,
417 },
418 });
419 }
420
421 await db
422 .insertInto("ci_secrets")
423 .values({
424 repo_id: repo.id,
425 name,
426 value,
427 description,
428 })
429 .onConflict((oc) =>
430 oc
431 .columns(["repo_id", "name"])
432 .doUpdateSet({ value, description }),
433 )
434 .execute();
435
436 return new Response(null, {
437 status: 302,
438 headers: {
439 Location: `/${params.repo}/settings?success=Secret+saved.`,
440 },
441 });
442 })
443
444 // Delete secret
445 .post(
446 "/:repo/settings/ci-secrets/delete",
447 async ({ params, body, cookie }) => {
448 const user = await resolveSession(cookie.session.value);
449 const deny = requireAdmin(user);
450 if (deny) return deny;
451 const repo = await db
452 .selectFrom("repositories")
453 .select("id")
454 .where("name", "=", params.repo)
455 .executeTakeFirst();
456 if (!repo) return new Response("Not found", { status: 404 });
457
458 const id = Number((body as Record<string, string>).id);
459 await db
460 .deleteFrom("ci_secrets")
461 .where("id", "=", id)
462 .where("repo_id", "=", repo.id)
463 .execute();
464
465 return new Response(null, {
466 status: 302,
467 headers: {
468 Location: `/${params.repo}/settings?success=Secret+deleted.`,
469 },
470 });
471 },
472 )
473
474 // Artifact download
475 .get(
476 "/:repo/ci/:runId/artifacts/:artifactId",
477 async ({ params, cookie }) => {
478 const user = await resolveSession(cookie.session.value);
479 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
480 if (!repo) return new Response("Not found", { status: 404 });
481
482 const runId = Number(params.runId);
483 const artifactId = Number(params.artifactId);
484
485 const artifact = await db
486 .selectFrom("ci_artifacts")
487 .innerJoin("ci_runs", "ci_runs.id", "ci_artifacts.run_id")
488 .select([
489 "ci_artifacts.id",
490 "ci_artifacts.filename",
491 "ci_artifacts.size",
492 ])
493 .where("ci_artifacts.id", "=", artifactId)
494 .where("ci_runs.id", "=", runId)
495 .where("ci_runs.repo_id", "=", repo.id)
496 .executeTakeFirst();
497 if (!artifact) return new Response("Not found", { status: 404 });
498
499 const filePath = path.join(
500 paths.CI_ARTIFACTS_DIR,
501 String(runId),
502 artifact.filename,
503 );
504 if (!existsSync(filePath))
505 return new Response("File not found", { status: 404 });
506
507 return new Response(Bun.file(filePath), {
508 headers: {
509 "Content-Disposition": `attachment; filename="${artifact.filename}"`,
510 "Content-Type": "application/octet-stream",
511 "Content-Length": String(artifact.size),
512 },
513 });
514 },
515 );
516