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