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