Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

712 lines · 32.6 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { parseWorkflow, type DispatchInput } from "@diffinery/workflow-yaml";
3import { raw } from "hono/html";
4import type { FC } from "hono/jsx";
5import { loadRepo } from "../http/common.js";
6import { HttpError } from "../lib/util.js";
7import type { JobInfo, PendingDeployment, RunInfo } from "../repo/actions.js";
8import type { DeploymentReview } from "../repo/deployments.js";
9import { reviewDeployments, viewerFor } from "../http/environment-reviews.js";
10import { atLeast } from "../repo/types.js";
11import { ICONS } from "./assets.js";
12import { KCheckbox, KSelect } from "./kumo.js";
13import { csrfToken } from "./csrf.js";
14import { ActionsBlockedBanner, actionsSwitches } from "./kill-switches.js";
15import { Actor, Form, Markdown, Pager, Status, Time, act, duration, encPath, fmtBytes, pageNum, post, qs, refNames, render, repoUrl, shortSha, type WebApp, prm } from "./ui.js";
16
17type Loaded = Awaited<ReturnType<typeof loadRepo>>;
18type LogLine = { seq: number; t: string; step: number; text: string };
19/** Plain (non-RPC-wrapped) shape of a run, which keeps type checking shallow. */
20type RunWithJobs = RunInfo & { jobs: JobInfo[] };
21
22const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
23const MAX_LOG_LINES = 20000;
24/** Conclusions "Re-run failed jobs" keeps (repo/actions.ts KEPT_CONCLUSIONS); the button shows when a job ended any other way. */
25const KEPT_ON_RERUN = new Set(["success", "skipped", "neutral"]);
26
27const refLabel = (ref: string): string => ref.replace(/^refs\/(heads|tags)\//, "");
28
29function runDuration(r: Pick<RunInfo, "status" | "createdAt" | "updatedAt">): string {
30 return duration(r.createdAt, r.status === "completed" ? r.updatedAt : null);
31}
32
33function matrixLabel(m: Record<string, unknown>): string {
34 const entries = Object.entries(m ?? {});
35 if (!entries.length) return "";
36 return entries.map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`).join(", ");
37}
38
39const RunRow: FC<{ base: string; r: RunInfo }> = ({ base, r }) => (
40 <li>
41 <Status status={r.status} conclusion={r.conclusion} />
42 <div class="grow">
43 <a class="title" href={`${base}/actions/runs/${r.id}`}>
44 {r.title || r.workflowName}
45 </a>
46 <div class="meta">
47 <b>{r.workflowName}</b> #{r.number}
48 {r.attempt > 1 ? ` (attempt ${r.attempt})` : ""}: {r.event.replace(/_/g, " ")}
49 {r.prNumber ? (
50 <>
51 {" "}
52 for <a href={`${base}/pull/${r.prNumber}`}>#{r.prNumber}</a>
53 </>
54 ) : null}{" "}
55 by {r.actor.login}
56 {r.actor.kind === "agent" ? <span class="badge agent" style="margin-left:4px">agent</span> : null}
57 </div>
58 </div>
59 <span class="badge mono nowrap" title={r.ref}>
60 {refLabel(r.ref)}
61 </span>
62 <div class="right small muted nowrap" style="min-width:110px">
63 <div>
64 <Time at={r.createdAt} />
65 </div>
66 <div>{runDuration(r)}</div>
67 </div>
68 </li>
69);
70
71/** Parse workflow_dispatch input definitions from the workflow file on the default branch. */
72async function dispatchInputs(l: Loaded, path: string): Promise<Record<string, DispatchInput> | null> {
73 try {
74 const blob = await l.stub.blob(l.repo.default_branch, path);
75 if (!blob || blob.binary) return null;
76 const { workflow } = parseWorkflow(new TextDecoder().decode(blob.content), path);
77 const d = workflow?.on.workflow_dispatch;
78 return d ? (d.inputs ?? {}) : null;
79 } catch {
80 return null;
81 }
82}
83
84function classify(text: string): { cls: string; text: string } {
85 const t = text.replace(ANSI, "");
86 if (t.startsWith("::group::")) return { cls: "grp", text: `▾ ${t.slice(9)}` };
87 if (t.startsWith("::endgroup::")) return { cls: "grp", text: "" };
88 if (/^(##\[error\]|::error)/.test(t)) return { cls: "err", text: t };
89 if (/^(##\[warning\]|::warning)/.test(t)) return { cls: "wrn", text: t };
90 return { cls: "", text: t };
91}
92
93async function allLogs(l: Loaded, jobId: string): Promise<{ lines: LogLine[]; complete: boolean; truncated: boolean }> {
94 const out: LogLine[] = [];
95 let after = -1;
96 let complete = false;
97 for (let i = 0; i < 5; i++) {
98 const page = await l.stub.jobLogs(jobId, after);
99 complete = page.complete;
100 out.push(...page.lines);
101 if (page.lines.length < 5000 || out.length >= MAX_LOG_LINES) break;
102 after = page.lines[page.lines.length - 1]!.seq;
103 }
104 return { lines: out.slice(0, MAX_LOG_LINES), complete, truncated: out.length >= MAX_LOG_LINES };
105}
106
107/** Environments the run waits on: who may approve, any wait timer, and a review form for listed reviewers. */
108const ReviewDeployments: FC<{ base: string; runId: string; pending: PendingDeployment[]; reviews: DeploymentReview[]; t: string; canAct: boolean }> = ({ base, runId, pending, reviews, t, canAct }) => {
109 const mine = pending.filter((p) => p.currentUserCanApprove);
110 return (
111 <div class="box" style="border-color:var(--amber)">
112 <div class="head">
113 <h3>Waiting for {pending.some((p) => p.needsReview) ? "review" : "a wait timer"}</h3>
114 </div>
115 <ul class="list">
116 {pending.map((p) => {
117 const timerEnds = new Date(Date.parse(p.waitTimerStartedAt) + p.waitTimer * 60_000).toISOString();
118 return (
119 <li>
120 <div class="grow">
121 <b>{p.environment.name}</b>
122 <div class="meta">
123 {p.needsReview ? <>Needs an approval from {p.reviewers.map((r) => r.login).join(", ")}. </> : p.reviewers.length ? "Approved. " : null}
124 {p.waitTimer > 0 ? (
125 <>
126 Wait timer of {p.waitTimer} minute{p.waitTimer === 1 ? "" : "s"}, ends <Time at={timerEnds} />.{" "}
127 </>
128 ) : null}
129 {p.selfBlocked ? "You started this run, and this environment doesn't let you approve it." : null}
130 </div>
131 </div>
132 </li>
133 );
134 })}
135 </ul>
136 {canAct && mine.length ? (
137 <Form action={`${base}/actions/runs/${runId}/review`} t={t} class="body" style="border-top:1px solid var(--border2)">
138 <div class="field">
139 <label>Environments to review</label>
140 {mine.map((p) => (
141 <div>
142 <KCheckbox name="environment" value={String(p.environment.id)} checked label={p.environment.name} />
143 </div>
144 ))}
145 </div>
146 <div class="field">
147 <label>Comment</label>
148 <textarea name="comment" maxlength={4000} style="min-height:60px" placeholder="Optional"></textarea>
149 </div>
150 <div class="row">
151 <button class="btn primary" name="state" value="approved">
152 Approve and deploy
153 </button>
154 <button class="btn danger" name="state" value="rejected">
155 Reject
156 </button>
157 </div>
158 </Form>
159 ) : null}
160 {reviews.length ? <ReviewHistory reviews={reviews} /> : null}
161 </div>
162 );
163};
164
165const ReviewHistory: FC<{ reviews: DeploymentReview[] }> = ({ reviews }) => (
166 <ul class="list">
167 {reviews.map((r) => (
168 <li>
169 <div class="grow small">
170 <b>{r.user.login}</b> {r.state} <b>{r.environment}</b> <Time at={r.createdAt} />
171 {r.comment ? <div class="muted">{r.comment}</div> : null}
172 </div>
173 </li>
174 ))}
175 </ul>
176);
177
178export function registerActions(web: WebApp): void {
179 web.get("/:owner/:repo/actions", async (c) => {
180 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
181 const { repo, caller, stub } = l;
182 const base = repoUrl(repo);
183 const workflow = c.req.query("workflow") ?? "";
184 const branch = c.req.query("branch") ?? "";
185 const status = c.req.query("status") ?? "";
186 const page = pageNum(c);
187 const per = 25;
188 const switches = actionsSwitches(c.env, repo.id);
189 const [runs, workflows, names] = await Promise.all([
190 stub.runs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, limit: per, offset: (page - 1) * per }),
191 stub.workflows().catch(() => [] as Array<{ path: string; name: string; errors: string[]; triggers: string[] }>),
192 refNames(stub),
193 ]);
194 const selected = workflow ? workflows.find((w) => w.path === workflow || w.path.endsWith(`/${workflow}`)) : undefined;
195 const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived;
196 const inputs = selected && canWrite && selected.triggers.includes("workflow_dispatch") ? await dispatchInputs(l, selected.path) : null;
197 const t = csrfToken(c);
198 const href = (p: Record<string, string | number | undefined>) => `${base}/actions${qs({ workflow: workflow || undefined, branch: branch || undefined, status: status || undefined, ...p })}`;
199 return render(
200 c,
201 { title: `Actions · ${repo.owner_login}/${repo.name}`, repo: { repo, caller, tab: "actions" }, live: "runs" },
202 <div class="grid left">
203 <aside class="side">
204 <section>
205 <h3>Workflows</h3>
206 <div class="nav">
207 <a href={`${base}/actions`} class={!workflow ? "on" : ""}>
208 All workflows
209 </a>
210 {workflows.map((w) => (
211 <a href={`${base}/actions${qs({ workflow: w.path })}`} class={selected?.path === w.path ? "on" : ""} title={w.path}>
212 {w.errors.length ? <span class="bad">{raw(ICONS.failure)}</span> : null} {w.name}
213 </a>
214 ))}
215 </div>
216 {workflows.length === 0 ? (
217 <p class="muted small">
218 No workflows on <span class="mono">{repo.default_branch}</span>. Add a YAML file under <code>.github/workflows/</code>.
219 </p>
220 ) : null}
221 </section>
222 <section>
223 <h3>Help</h3>
224 <p class="small muted m0">Workflows use GitHub Actions syntax. Jobs run on Linux containers; secrets and variables are configured in repository settings.</p>
225 </section>
226 </aside>
227 <div>
228 <ActionsBlockedBanner env={c.env} repoId={repo.id} switches={switches} />
229 <div class="row between mb">
230 <h1 class="m0">{selected ? selected.name : "All workflows"}</h1>
231 {selected ? <span class="mono muted small">{selected.path}</span> : null}
232 </div>
233 {selected?.errors.length ? (
234 <div class="failure">
235 <b>This workflow file has errors</b>
236 <pre>{selected.errors.join("\n")}</pre>
237 </div>
238 ) : null}
239 {!selected && workflows.some((w) => w.errors.length) ? (
240 <div class="failure">
241 <b>Some workflow files have errors</b>
242 <pre>{workflows.filter((w) => w.errors.length).map((w) => `${w.path}\n ${w.errors.join("\n ")}`).join("\n")}</pre>
243 </div>
244 ) : null}
245 {selected && inputs ? (
246 <details class="box">
247 <summary class="head" style="cursor:pointer">
248 {raw(ICONS.play)} <b>Run workflow</b>
249 <span class="muted small">This workflow has a workflow_dispatch trigger.</span>
250 </summary>
251 <Form action={`${base}/actions/dispatch`} t={t} class="body">
252 <input type="hidden" name="workflow" value={selected.path} />
253 <div class="field" style="max-width:320px">
254 <label>Use workflow from</label>
255 <KSelect
256 name="ref"
257 block
258 label="Use workflow from"
259 value={names.branches.includes(repo.default_branch) ? repo.default_branch : undefined}
260 options={[...names.branches.map((b) => ({ value: b, label: b })), ...names.tags.map((tg) => ({ value: tg, label: `tag: ${tg}` }))]}
261 />
262 </div>
263 {Object.entries(inputs).map(([name, d]) => (
264 <div class="field" style="max-width:520px">
265 {d.type === "boolean" ? (
266 <>
267 <input type="hidden" name={`in_${name}`} value="false" />
268 <KCheckbox name={`in_${name}`} value="true" checked={d.default === true || d.default === "true"} label={d.description ?? name} />
269 </>
270 ) : (
271 <>
272 <label>
273 {d.description ?? name} {d.required ? <span class="bad">*</span> : null}
274 </label>
275 {d.type === "choice" && d.options?.length ? (
276 <KSelect
277 name={`in_${name}`}
278 block
279 label={d.description ?? name}
280 value={d.options.includes(String(d.default ?? "")) ? String(d.default ?? "") : undefined}
281 options={d.options.map((o) => ({ value: o, label: o }))}
282 />
283 ) : (
284 <input type={d.type === "number" ? "number" : "text"} name={`in_${name}`} value={d.default === undefined || d.default === null ? "" : String(d.default)} required={!!d.required} />
285 )}
286 </>
287 )}
288 {d.description && d.type !== "boolean" ? <div class="hint mono">{name}</div> : null}
289 </div>
290 ))}
291 <button class="btn green">{raw(ICONS.play)} Run workflow</button>
292 </Form>
293 </details>
294 ) : null}
295 <div class="box" data-fragment="runs">
296 <div class="head">
297 <b>
298 {runs.total} workflow run{runs.total === 1 ? "" : "s"}
299 </b>
300 <span class="grow" />
301 <form method="get" action={`${base}/actions`} class="row" style="gap:6px">
302 {workflow ? <input type="hidden" name="workflow" value={workflow} /> : null}
303 <KSelect
304 name="branch"
305 size="sm"
306 label="Branch"
307 autosubmit
308 value={branch && names.branches.includes(branch) ? branch : ""}
309 options={[{ value: "", label: "All branches" }, ...names.branches.map((b) => ({ value: b, label: b }))]}
310 />
311 <KSelect
312 name="status"
313 size="sm"
314 label="Status"
315 autosubmit
316 value={status ?? ""}
317 options={[{ value: "", label: "Any status" }, ...["queued", "in_progress", "completed", "success", "failure", "cancelled"].map((s) => ({ value: s, label: s.replace("_", " ") }))]}
318 />
319 <button class="btn sm">Filter</button>
320 </form>
321 </div>
322 {runs.runs.length ? (
323 <ul class="list">
324 {runs.runs.map((r) => (
325 <RunRow base={base} r={r} />
326 ))}
327 </ul>
328 ) : (
329 <div class="empty">{workflow || branch || status ? "No runs match these filters." : "No workflow runs yet. Push a commit with a workflow file to start one."}</div>
330 )}
331 </div>
332 <Pager page={page} hasNext={page * per < runs.total} href={(p) => href({ page: p > 1 ? p : undefined })} />
333 </div>
334 </div>,
335 );
336 });
337
338 post(web, "/:owner/:repo/actions/dispatch", async (c, f) => {
339 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
340 if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
341 const base = repoUrl(l.repo);
342 const workflow = f.s("workflow");
343 const inputs: Record<string, unknown> = {};
344 for (const key of f.keys()) if (key.startsWith("in_")) inputs[key.slice(3)] = f.all(key).pop();
345 return act(c, `${base}/actions${qs({ workflow })}`, async () => {
346 const run = await l.stub.dispatchWorkflow(workflow, f.s("ref") || l.repo.default_branch, inputs, l.caller);
347 return `${base}/actions/runs/${run.id}`;
348 });
349 });
350
351 web.get("/:owner/:repo/actions/runs/:runId", async (c) => {
352 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
353 const { repo, caller, stub } = l;
354 const base = repoUrl(repo);
355 const runId = prm(c, "runId");
356 // The run, its artifacts and the viewer side by side, then its deployments (they need the run's own id and status).
357 const switches = actionsSwitches(c.env, repo.id);
358 const [run, artifacts, viewer] = await Promise.all([
359 stub.run(runId) as unknown as Promise<RunWithJobs>,
360 stub.artifacts(runId).catch(() => [] as Array<{ name: string; size: number; createdAt: string }>),
361 viewerFor(c, repo),
362 ]);
363 const principal = c.get("principal");
364 const [pending, reviews] = await Promise.all([run.status !== "completed" ? stub.pendingDeployments(run.id, viewer) : Promise.resolve([] as PendingDeployment[]), stub.deploymentReviews(run.id)]);
365 const groups = new Map<string, JobInfo[]>();
366 for (const j of run.jobs) groups.set(j.key, [...(groups.get(j.key) ?? []), j]);
367 const canWrite = atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived;
368 const t = csrfToken(c);
369 const behalf = run.actor.onBehalfOf;
370 return render(
371 c,
372 { title: `${run.title || run.workflowName} · Run #${run.number}`, repo: { repo, caller, tab: "actions" }, live: run.status !== "completed" ? `run:${run.id}` : undefined },
373 <div data-fragment="run">
374 <ActionsBlockedBanner env={c.env} repoId={repo.id} switches={switches} />
375 <div class="row between mb" style="align-items:flex-start">
376 <div class="grow">
377 <div class="muted small">
378 <a href={`${base}/actions${qs({ workflow: run.workflowPath })}`}>{run.workflowName}</a> · run #{run.number}
379 {run.attempt > 1 ? ` · attempt ${run.attempt}` : ""}
380 </div>
381 <h1 class="row" style="gap:8px;margin-top:4px">
382 <Status status={run.status} conclusion={run.conclusion} /> {run.title || run.workflowName}
383 </h1>
384 </div>
385 <div class="row">
386 {canWrite && run.status !== "completed" ? (
387 <Form action={`${base}/actions/runs/${run.id}/cancel`} t={t} class="inline" confirm="Cancel this run?">
388 <button class="btn danger">{raw(ICONS.cancelled)} Cancel run</button>
389 </Form>
390 ) : null}
391 {canWrite && run.status === "completed" && run.jobs.some((j) => !KEPT_ON_RERUN.has(j.conclusion ?? "")) ? (
392 <Form action={`${base}/actions/runs/${run.id}/rerun-failed`} t={t} class="inline">
393 <button class="btn">{raw(ICONS.history)} Re-run failed jobs</button>
394 </Form>
395 ) : null}
396 {canWrite && run.status === "completed" ? (
397 <Form action={`${base}/actions/runs/${run.id}/rerun`} t={t} class="inline">
398 <button class="btn">{raw(ICONS.history)} Re-run all jobs</button>
399 </Form>
400 ) : null}
401 </div>
402 </div>
403 <div class="box">
404 <div class="body">
405 <dl class="kv">
406 <dt>Status</dt>
407 <dd>
408 <b>{run.status === "completed" ? (run.conclusion ?? "completed") : run.status.replace("_", " ")}</b>
409 {run.status === "completed" ? <span class="muted"> in {runDuration(run)}</span> : <span class="muted"> for {runDuration(run)}</span>}
410 </dd>
411 <dt>Triggered</dt>
412 <dd>
413 {run.event.replace(/_/g, " ")} by <Actor login={run.actor.login} kind={run.actor.kind} onBehalfOf={behalf} avatar={false} /> <Time at={run.createdAt} />
414 </dd>
415 <dt>Commit</dt>
416 <dd>
417 <a class="sha" href={`${base}/commit/${run.sha}`}>
418 {shortSha(run.sha)}
419 </a>{" "}
420 on{" "}
421 <a class="mono" href={`${base}/tree/${encPath(refLabel(run.ref))}`}>
422 {refLabel(run.ref)}
423 </a>
424 {run.prNumber ? (
425 <>
426 {" "}
427 · pull request <a href={`${base}/pull/${run.prNumber}`}>#{run.prNumber}</a>
428 </>
429 ) : null}
430 </dd>
431 <dt>Workflow file</dt>
432 <dd>
433 <a class="mono" href={`${base}/blob/${run.sha}/${encPath(run.workflowPath)}`}>
434 {run.workflowPath}
435 </a>
436 </dd>
437 </dl>
438 </div>
439 </div>
440 {pending.length ? (
441 <ReviewDeployments base={base} runId={run.id} pending={pending} reviews={reviews} t={t} canAct={principal.type === "account" && principal.account.kind === "user" && !repo.archived} />
442 ) : reviews.length ? (
443 <div class="box">
444 <div class="head">
445 <h3>Deployment reviews</h3>
446 </div>
447 <ReviewHistory reviews={reviews} />
448 </div>
449 ) : null}
450 {[...groups.entries()].map(([key, jobs]) => (
451 <div class="box jobgroup">
452 <div class="head">
453 <b class="mono">{key}</b>
454 {jobs.length > 1 ? <span class="muted small">{jobs.length} jobs (matrix)</span> : null}
455 </div>
456 {jobs.map((j) => (
457 <a class="job" href={`${base}/actions/runs/${run.id}/job/${j.id}`}>
458 <Status status={j.status} conclusion={j.conclusion} />
459 <div class="grow">
460 <b>{j.name}</b>
461 {matrixLabel(j.matrix) && !j.name.includes("(") ? <span class="muted small"> ({matrixLabel(j.matrix)})</span> : null}
462 {j.failure ? <div class="small bad">{j.failure.message.split("\n")[0]}</div> : null}
463 {j.carriedFrom ? <div class="small muted">Kept from an earlier attempt</div> : null}
464 </div>
465 <span class="muted small nowrap">{j.startedAt ? duration(j.startedAt, j.completedAt) : j.status === "approval" ? "waiting for approval" : j.status}</span>
466 </a>
467 ))}
468 </div>
469 ))}
470 {run.jobs.length === 0 ? <div class="blank mb">{run.status === "completed" ? "This run has no jobs." : "Waiting for jobs to be scheduled…"}</div> : null}
471 {artifacts.length ? (
472 <div class="box">
473 <div class="head">
474 <h3>{raw(ICONS.download)} Artifacts</h3>
475 </div>
476 <ul class="list">
477 {artifacts.map((a) => (
478 <li>
479 <div class="grow">
480 <a class="title" href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(a.name)}`}>
481 {a.name}
482 </a>
483 <div class="meta">
484 {fmtBytes(a.size)} · <Time at={a.createdAt} />
485 </div>
486 </div>
487 </li>
488 ))}
489 </ul>
490 </div>
491 ) : null}
492 </div>,
493 );
494 });
495
496 post(web, "/:owner/:repo/actions/runs/:runId/review", async (c, f) => {
497 // Listed reviewers may have read access only; the rules are checked again where the decision is made.
498 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
499 const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`;
500 const state = f.s("state");
501 return act(
502 c,
503 back,
504 async () => {
505 await reviewDeployments(c, l, prm(c, "runId"), { environment_ids: f.all("environment").map(Number), state, comment: f.s("comment") });
506 },
507 state === "approved" ? "Approved. The jobs start once any wait timer has passed." : "Rejected. The waiting jobs fail.",
508 );
509 });
510
511 post(web, "/:owner/:repo/actions/runs/:runId/cancel", async (c) => {
512 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
513 const back = `${repoUrl(l.repo)}/actions/runs/${prm(c, "runId")}`;
514 return act(c, back, async () => l.stub.cancelRun(prm(c, "runId"), l.caller), "Cancellation requested.");
515 });
516
517 for (const [path, scope] of [["rerun", "all"], ["rerun-failed", "failed"]] as const) {
518 post(web, `/:owner/:repo/actions/runs/:runId/${path}`, async (c) => {
519 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
520 const base = repoUrl(l.repo);
521 return act(c, `${base}/actions/runs/${prm(c, "runId")}`, async () => {
522 const run = await l.stub.rerun(prm(c, "runId"), l.caller, scope);
523 return `${base}/actions/runs/${run.id}`;
524 });
525 });
526 }
527
528 post(web, "/:owner/:repo/actions/runs/:runId/job/:jobId/rerun", async (c) => {
529 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write");
530 const base = repoUrl(l.repo);
531 return act(c, `${base}/actions/runs/${prm(c, "runId")}/job/${prm(c, "jobId")}`, async () => {
532 const job = (await l.stub.job(prm(c, "jobId"))) as unknown as JobInfo;
533 if (job.runId !== prm(c, "runId")) throw new HttpError(404, "job not found");
534 const run = await l.stub.rerunJob(job.id, l.caller);
535 return `${base}/actions/runs/${run.id}`;
536 });
537 });
538
539 web.get("/:owner/:repo/actions/runs/:runId/job/:jobId", async (c) => {
540 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
541 const { repo, caller, stub } = l;
542 const base = repoUrl(repo);
543 const runId = prm(c, "runId");
544 const jobId = prm(c, "jobId");
545 const run = (await stub.run(runId)) as unknown as RunWithJobs;
546 const job = (await stub.job(jobId)) as unknown as JobInfo;
547 if (job.runId !== run.id) throw new HttpError(404, "job not found");
548 const logs = await allLogs(l, jobId);
549 const byStep = new Map<number, LogLine[]>();
550 for (const line of logs.lines) byStep.set(line.step, [...(byStep.get(line.step) ?? []), line]);
551 const stepIdx = new Set<number>([...job.steps.map((s) => s.index), ...byStep.keys()]);
552 const sections = [...stepIdx].sort((a, b) => a - b);
553 const stepOf = new Map(job.steps.map((s) => [s.index, s]));
554 const lastSeq = logs.lines.length ? logs.lines[logs.lines.length - 1]!.seq : -1;
555 const live = job.status !== "completed";
556 const failedStep = job.failure?.step;
557 const canRerun = run.status === "completed" && atLeast(caller.role, "write") && c.get("principal").type === "account" && !repo.archived;
558 return render(
559 c,
560 { title: `${job.name} · ${run.title || run.workflowName}`, repo: { repo, caller, tab: "actions" }, live: live ? `job:${job.id}` : undefined, liveMode: "log", lastSeq, wide: true },
561 <div class="grid left">
562 <aside class="side">
563 <section>
564 <a href={`${base}/actions/runs/${run.id}`} class="small">
565 ← {run.workflowName} #{run.number}
566 </a>
567 <h3 class="mt">Jobs</h3>
568 <div class="nav">
569 {run.jobs.map((j) => (
570 <a href={`${base}/actions/runs/${run.id}/job/${j.id}`} class={j.id === job.id ? "on" : ""} style="display:flex;gap:6px;align-items:center">
571 <Status status={j.status} conclusion={j.conclusion} /> <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{j.name}</span>
572 </a>
573 ))}
574 </div>
575 </section>
576 </aside>
577 <div>
578 <div class="row between mb">
579 <h1 class="row m0" style="gap:8px">
580 <Status status={job.status} conclusion={job.conclusion} /> {job.name}
581 </h1>
582 <div class="row">
583 <span class="muted small">
584 {job.startedAt ? (
585 <>
586 {job.status === "completed" ? "took" : "running for"} {duration(job.startedAt, job.completedAt)}
587 </>
588 ) : (
589 job.status
590 )}
591 </span>
592 <a class="btn sm" href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}>
593 {raw(ICONS.download)} Raw logs
594 </a>
595 {canRerun ? (
596 <Form action={`${base}/actions/runs/${run.id}/job/${job.id}/rerun`} t={csrfToken(c)} class="inline">
597 <button class="btn sm">{raw(ICONS.history)} Re-run this job</button>
598 </Form>
599 ) : null}
600 </div>
601 </div>
602 {matrixLabel(job.matrix) ? <p class="muted small">Matrix: {matrixLabel(job.matrix)}</p> : null}
603 {job.carriedFrom ? <p class="muted small">Kept from an earlier attempt of this run: it didn't run again in attempt {run.attempt}.</p> : null}
604 {job.failure ? (
605 <div class="failure">
606 <b>
607 {raw(ICONS.failure)} {job.conclusion === "cancelled" ? "Cancelled" : job.conclusion === "timed_out" ? "Timed out" : "Failed"}
608 {failedStep !== undefined && failedStep !== null ? (
609 <>
610 {" "}
611 at{" "}
612 <a href={`#step-${failedStep}`} class="bad">
613 {stepOf.get(failedStep)?.name ?? `step ${failedStep + 1}`}
614 </a>
615 </>
616 ) : null}
617 </b>
618 <pre>{job.failure.message}</pre>
619 </div>
620 ) : null}
621 {logs.truncated ? (
622 <div class="flash info">
623 Showing the first {MAX_LOG_LINES.toLocaleString("en-US")} lines. <a href={`${base}/actions/runs/${run.id}/job/${job.id}/logs`}>Download the full log</a>.
624 </div>
625 ) : null}
626 <div class="log">
627 {sections.length === 0 ? <div style="padding:14px;color:#9aa3ad">{live ? "Waiting for the runner to start…" : "No log output."}</div> : null}
628 {sections.map((idx) => {
629 const s = stepOf.get(idx);
630 const lines = byStep.get(idx) ?? [];
631 const failed = s?.conclusion === "failure" || failedStep === idx;
632 const running = s?.status === "in_progress";
633 return (
634 <details data-step={String(idx)} id={`step-${idx}`} open={failed || running || (live && idx === sections[sections.length - 1])}>
635 <summary>
636 {s ? (
637 <Status status={s.status} conclusion={s.conclusion} />
638 ) : idx < 0 ? (
639 <Status conclusion={job.startedAt ? "success" : "queued"} />
640 ) : (
641 <Status conclusion="neutral" />
642 )}
643 {idx < 0 ? "Set up job" : (s?.name ?? `Step ${idx + 1}`)}
644 <span class="dur">{s?.startedAt ? duration(s.startedAt, s.completedAt) : ""}</span>
645 </summary>
646 <pre>
647 {lines.map((ln) => {
648 const k = classify(ln.text);
649 return <span class={k.cls || undefined}>{k.text}</span>;
650 })}
651 </pre>
652 </details>
653 );
654 })}
655 </div>
656 {job.summary ? (
657 <div class="box">
658 <div class="head">
659 <h3>Job summary</h3>
660 </div>
661 <div class="body">
662 <Markdown text={job.summary} />
663 </div>
664 </div>
665 ) : null}
666 {Object.keys(job.outputs ?? {}).length ? (
667 <div class="box">
668 <div class="head">
669 <h3>Outputs</h3>
670 </div>
671 <div class="body">
672 <dl class="kv">
673 {Object.entries(job.outputs).map(([k, v]) => (
674 <>
675 <dt class="mono">{k}</dt>
676 <dd class="mono" style="word-break:break-all">
677 {v}
678 </dd>
679 </>
680 ))}
681 </dl>
682 </div>
683 </div>
684 ) : null}
685 </div>
686 </div>,
687 );
688 });
689
690 web.get("/:owner/:repo/actions/runs/:runId/job/:jobId/logs", async (c) => {
691 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
692 const job = await l.stub.job(prm(c, "jobId"));
693 if (job.runId !== prm(c, "runId")) throw new HttpError(404, "job not found");
694 const out: string[] = [];
695 let after = -1;
696 for (let i = 0; i < 40; i++) {
697 const page = await l.stub.jobLogs(job.id, after);
698 for (const ln of page.lines) out.push(`${ln.t} ${ln.text.replace(ANSI, "")}`);
699 if (page.lines.length < 5000) break;
700 after = page.lines[page.lines.length - 1]!.seq;
701 }
702 return new Response(`${out.join("\n")}\n`, {
703 headers: {
704 "content-type": "text/plain; charset=utf-8",
705 "x-content-type-options": "nosniff",
706 "content-security-policy": "sandbox",
707 "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(`${job.name}.log`)}`,
708 "cache-control": "private, no-store",
709 },
710 });
711 });
712}