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 success={query.success}
207 error={query.error}
208 />,
209 );
210 },
211 {
212 query: t.Object({
213 page: t.Optional(t.Number()),
214 success: t.Optional(t.String()),
215 error: t.Optional(t.String()),
216 }),
217 },
218 )
219
220 // Run detail
221 .get(
222 "/:repo/ci/:runId",
223 async ({ params, query, cookie }) => {
224 const user = await resolveSession(cookie.session.value);
225 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
226 if (!repo) return new Response("Not found", { status: 404 });
227
228 const runId = Number(params.runId);
229 const run = await db
230 .selectFrom("ci_runs")
231 .leftJoin("users", "users.id", "ci_runs.triggered_by")
232 .select([
233 "ci_runs.id",
234 "ci_runs.repo_run_id",
235 "ci_runs.status",
236 "ci_runs.trigger_source",
237 "ci_runs.commit_sha",
238 "ci_runs.commit_branch",
239 "ci_runs.commit_tag",
240 "ci_runs.variable_overrides",
241 "ci_runs.started_at",
242 "ci_runs.finished_at",
243 "ci_runs.created_at",
244 "users.username as triggered_by_username",
245 ])
246 .where("ci_runs.id", "=", runId)
247 .where("ci_runs.repo_id", "=", repo.id)
248 .executeTakeFirst();
249 if (!run) return new Response("Not found", { status: 404 });
250
251 const steps = await db
252 .selectFrom("ci_steps")
253 .selectAll()
254 .where("run_id", "=", runId)
255 .orderBy("id", "asc")
256 .execute();
257
258 const artifacts = await db
259 .selectFrom("ci_artifacts")
260 .selectAll()
261 .where("run_id", "=", runId)
262 .orderBy("id", "asc")
263 .execute();
264
265 return html(
266 <CiRunDetail
267 user={user}
268 repo={repo}
269 run={run}
270 steps={steps}
271 artifacts={artifacts}
272 autoRefresh={query.refresh !== "off"}
273 queuePosition={
274 run.status === "queued" ? ciQueuePosition(run.id) : null
275 }
276 />,
277 );
278 },
279 { query: t.Object({ refresh: t.Optional(t.String()) }) },
280 )
281
282 // Manual trigger
283 .post("/:repo/ci/run", async ({ params, body, cookie }) => {
284 const user = await resolveSession(cookie.session.value);
285 const deny = requireAdmin(user);
286 if (deny) return deny;
287 const repo = await getRepo(params.repo, true);
288 if (!repo) return new Response("Not found", { status: 404 });
289
290 // Read CI config at HEAD to check manual trigger is allowed and get variable definitions
291 const branches = await git.branches(repo.name);
292 const defaultBranch = repo.default_branch || branches[0];
293 if (!defaultBranch) return new Response("No branches", { status: 400 });
294
295 const headLog = await git.log(repo.name, defaultBranch, 1);
296 if (!headLog.length) return new Response("No commits", { status: 400 });
297 const headSha = headLog[0]!.hash;
298
299 const tomlBuf = await git.show(
300 repo.name,
301 headSha,
302 ".hearthforge-ci.toml",
303 );
304 if (!tomlBuf)
305 return new Response(
306 "No .hearthforge-ci.toml found at HEAD. Add one to your repository to use CI pipelines.",
307 { status: 400 },
308 );
309 const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
310 if (!cfg)
311 return new Response(
312 "Failed to parse .hearthforge-ci.toml. Check the file for syntax errors.",
313 { status: 400 },
314 );
315
316 // Parse variable overrides from form body
317 // Elysia leaves body undefined for a POST with an empty body, which
318 // is what a form with no filled-in inputs sends.
319 const form = (body ?? {}) as Record<string, string>;
320 const variableOverrides: Record<string, string> = {};
321 for (const [varName, def] of Object.entries(cfg.variables ?? {})) {
322 const val = form[`var_${varName}`];
323 // An untouched field is not an override. Sending the default back
324 // would pin the run to the value the config had at render time.
325 if (typeof val === "string" && val !== (def.default ?? "")) {
326 variableOverrides[varName] = val;
327 }
328 }
329
330 const runId = await triggerRun(repo.name, {
331 triggerSource: "manual",
332 commitSha: headSha,
333 commitBranch: defaultBranch,
334 triggeredBy: user!.id,
335 variableOverrides,
336 });
337
338 return new Response(null, {
339 status: 302,
340 headers: { Location: `/${repo.name}/ci/${runId}` },
341 });
342 })
343
344 // Retry
345 .post("/:repo/ci/:runId/retry", 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 existing = await db
354 .selectFrom("ci_runs")
355 .select("id")
356 .where("id", "=", runId)
357 .where("repo_id", "=", repo.id)
358 .executeTakeFirst();
359 if (!existing) return new Response("Not found", { status: 404 });
360
361 await retryRun(runId, user!.id);
362
363 return new Response(null, {
364 status: 302,
365 headers: { Location: `/${repo.name}/ci/${runId}` },
366 });
367 })
368
369 // Cancel
370 .post("/:repo/ci/:runId/cancel", 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 const runId = Number(params.runId);
378 const run = await db
379 .selectFrom("ci_runs")
380 .select("id")
381 .where("id", "=", runId)
382 .where("repo_id", "=", repo.id)
383 .executeTakeFirst();
384 if (!run) return new Response("Not found", { status: 404 });
385
386 await cancelRun(runId);
387
388 return new Response(null, {
389 status: 302,
390 headers: { Location: `/${repo.name}/ci/${runId}` },
391 });
392 })
393
394 // Purge cache volumes
395 .post("/:repo/ci/purge-cache", async ({ params, cookie }) => {
396 const user = await resolveSession(cookie.session.value);
397 const deny = requireAdmin(user);
398 if (deny) return deny;
399 const repo = await getRepo(params.repo, true);
400 if (!repo) return new Response("Not found", { status: 404 });
401
402 let message: string;
403 try {
404 const removed = await purgeRepoCaches(repo.name);
405 message = `success=${encodeURIComponent(
406 removed === 0
407 ? "No cache volumes to purge."
408 : `Purged ${removed} cache volume${removed === 1 ? "" : "s"}.`,
409 )}`;
410 } catch {
411 message = `error=${encodeURIComponent("Failed to purge caches. Is Docker reachable?")}`;
412 }
413
414 return new Response(null, {
415 status: 302,
416 headers: {
417 Location: `/${repo.name}/ci?${message}`,
418 },
419 });
420 })
421
422 // Create secret
423 .post("/:repo/settings/ci-secrets", async ({ params, body, cookie }) => {
424 const user = await resolveSession(cookie.session.value);
425 const deny = requireAdmin(user);
426 if (deny) return deny;
427 const repo = await db
428 .selectFrom("repositories")
429 .select("id")
430 .where("name", "=", params.repo)
431 .executeTakeFirst();
432 if (!repo) return new Response("Not found", { status: 404 });
433
434 const name = (body as Record<string, string>).name?.trim();
435 const value = (body as Record<string, string>).value;
436 const description =
437 (body as Record<string, string>).description?.trim() || null;
438
439 if (!name || !/^[A-Z_][A-Z0-9_]*$/i.test(name)) {
440 return new Response(null, {
441 status: 302,
442 headers: {
443 Location: `/${params.repo}/settings?error=${encodeURIComponent("Secret name must be a valid identifier.")}`,
444 },
445 });
446 }
447 if (!value) {
448 return new Response(null, {
449 status: 302,
450 headers: {
451 Location: `/${params.repo}/settings?error=${encodeURIComponent("Secret value cannot be empty.")}`,
452 },
453 });
454 }
455
456 await db
457 .insertInto("ci_secrets")
458 .values({
459 repo_id: repo.id,
460 name,
461 value,
462 description,
463 })
464 .onConflict((oc) =>
465 oc
466 .columns(["repo_id", "name"])
467 .doUpdateSet({ value, description }),
468 )
469 .execute();
470
471 return new Response(null, {
472 status: 302,
473 headers: {
474 Location: `/${params.repo}/settings?success=Secret+saved.`,
475 },
476 });
477 })
478
479 // Delete secret
480 .post(
481 "/:repo/settings/ci-secrets/delete",
482 async ({ params, body, cookie }) => {
483 const user = await resolveSession(cookie.session.value);
484 const deny = requireAdmin(user);
485 if (deny) return deny;
486 const repo = await db
487 .selectFrom("repositories")
488 .select("id")
489 .where("name", "=", params.repo)
490 .executeTakeFirst();
491 if (!repo) return new Response("Not found", { status: 404 });
492
493 const id = Number((body as Record<string, string>).id);
494 await db
495 .deleteFrom("ci_secrets")
496 .where("id", "=", id)
497 .where("repo_id", "=", repo.id)
498 .execute();
499
500 return new Response(null, {
501 status: 302,
502 headers: {
503 Location: `/${params.repo}/settings?success=Secret+deleted.`,
504 },
505 });
506 },
507 )
508
509 // Artifact download
510 .get(
511 "/:repo/ci/:runId/artifacts/:artifactId",
512 async ({ params, cookie }) => {
513 const user = await resolveSession(cookie.session.value);
514 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
515 if (!repo) return new Response("Not found", { status: 404 });
516
517 const runId = Number(params.runId);
518 const artifactId = Number(params.artifactId);
519
520 const artifact = await db
521 .selectFrom("ci_artifacts")
522 .innerJoin("ci_runs", "ci_runs.id", "ci_artifacts.run_id")
523 .select([
524 "ci_artifacts.id",
525 "ci_artifacts.filename",
526 "ci_artifacts.size",
527 ])
528 .where("ci_artifacts.id", "=", artifactId)
529 .where("ci_runs.id", "=", runId)
530 .where("ci_runs.repo_id", "=", repo.id)
531 .executeTakeFirst();
532 if (!artifact) return new Response("Not found", { status: 404 });
533
534 const filePath = path.join(
535 paths.CI_ARTIFACTS_DIR,
536 String(runId),
537 artifact.filename,
538 );
539 if (!existsSync(filePath))
540 return new Response("File not found", { status: 404 });
541
542 return new Response(Bun.file(filePath), {
543 headers: {
544 "Content-Disposition": contentDisposition(
545 "attachment",
546 artifact.filename,
547 ),
548 "Content-Type": "application/octet-stream",
549 "Content-Length": String(artifact.size),
550 },
551 });
552 },
553 );
554