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