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