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