Skip to content

Commit

Run page: job summaries, attempts, re-run a job, debug, log search and downloads

The run's page renders each job's $GITHUB_STEP_SUMMARY (sanitised Markdown) above its jobs, picks among its attempts (an earlier one shows its jobs and logs as they ended), re-runs one job or the failed ones or all of them from a dialog that can turn on debug logging, and offers Force cancel while a cancelled run's jobs clean up. A job's log can be searched (matching lines only, marked) and downloaded as text, and every job's log of an attempt as a zip (actions/runs/:id/logs.zip).

syntaqxcommitted Parent8b12c83Browse files
10 files+677−940/10 viewed
+29−40
1010 import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts";
1111
1212 import { Hint } from "./ui/hint";
13+import { type Line, blocks, highlight, searchLog } from "../lib/log-lines";
1314
1415 /**
1516 * Where a run, job or step stands. `of` says which: a run's `pending` waits
8788 const pull = /^refs\/pull\/(\d+)\//.exec(ref);
8889 if (pull) return `#${pull[1]}`;
8990 return ref.replace(/^refs\/(heads|tags)\//, "");
90−}
91−
92−type Line = { kind: "text" | "error" | "warning" | "notice" | "debug" | "command"; text: string };
93−type Block = { kind: "line"; line: Line; number: number } | { kind: "group"; title: string; lines: { line: Line; number: number }[] };
94−
95−function classify(raw: string): Line | "group-end" | { group: string } {
96− if (raw.startsWith("##[group]")) return { group: raw.slice(9) };
97− if (raw.startsWith("##[endgroup]")) return "group-end";
98− for (const kind of ["error", "warning", "notice", "debug"] as const) {
99− if (raw.startsWith(`##[${kind}]`)) return { kind, text: raw.slice(kind.length + 4) };
100− }
101− if (raw.startsWith("[command]")) return { kind: "command", text: raw.slice(9) };
102− return { kind: "text", text: raw };
10391 }
10492
105−/** Lines into blocks: plain lines, and groups that fold. */
106−function blocks(text: string): Block[] {
107− const out: Block[] = [];
108− let group: Extract<Block, { kind: "group" }> | null = null;
109− let number = 0;
110− for (const raw of text.split("\n")) {
111− if (raw === "" && number === 0) continue;
112− const line = classify(raw);
113− if (line === "group-end") {
114− group = null;
115− continue;
116− }
117− if ("group" in line) {
118− group = { kind: "group", title: line.group, lines: [] };
119− out.push(group);
120− continue;
121− }
122− number += 1;
123− if (group) group.lines.push({ line, number });
124− else out.push({ kind: "line", line, number });
125− }
126− return out;
127−}
128−
12993 const LINE_STYLE: Record<Line["kind"], string> = {
13094 text: "text-fg/85",
13195 error: "text-danger",
13599 command: "text-muted",
136100 };
137101
138−function LogLine({ line, number }: { line: Line; number: number }) {
102+function LogLine({ line, number, query = "" }: { line: Line; number: number; query?: string }) {
103+ const text = line.text.replace(/^(Error|Warning|Notice): /, "");
139104 return (
140105 <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}>
141106 <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span>
142107 <span className={`min-w-0 whitespace-pre-wrap break-all ${LINE_STYLE[line.kind]}`}>
143108 {line.kind === "error" && <span className="font-semibold">Error: </span>}
144109 {line.kind === "warning" && <span className="font-semibold">Warning: </span>}
145− {line.text.replace(/^(Error|Warning|Notice): /, "")}
110+ {query
111+ ? highlight(text, query).map((piece, index) =>
112+ piece.match ? (
113+ <mark key={index} className="rounded-sm bg-accent/30 text-fg">
114+ {piece.text}
115+ </mark>
116+ ) : (
117+ piece.text
118+ ),
119+ )
120+ : text}
146121 </span>
147122 </div>
148123 );
149124 }
150125
151−export function LogText({ text }: { text: string }) {
126+/**
127+ * A step's log. With `query`, only the lines holding it (any case), groups
128+ * opened, each match marked.
129+ */
130+export function LogText({ text, query = "" }: { text: string; query?: string }) {
152131 const parsed = useMemo(() => blocks(text), [text]);
132+ const found = useMemo(() => searchLog(text, query), [text, query]);
153133 if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>;
134+ if (query.trim()) {
135+ return (
136+ <div className="py-1 font-mono text-xs leading-5">
137+ {found.map(({ line, number }) => (
138+ <LogLine key={number} line={line} number={number} query={query} />
139+ ))}
140+ </div>
141+ );
142+ }
154143 return (
155144 <div className="py-1 font-mono text-xs leading-5">
156145 {parsed.map((block, index) =>
+15−0
1+/** Answers for the log downloads: a file to save, or why there is none. */
2+
3+export function refused(status: number, message: string): Response {
4+ return new Response(`${message}\n`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
5+}
6+
7+export function attachment(body: BodyInit, type: string, file: string): Response {
8+ return new Response(body, {
9+ headers: {
10+ "content-type": type,
11+ "content-disposition": `attachment; filename="${file.replace(/["\\\r\n]/g, "")}"`,
12+ "cache-control": "no-store",
13+ },
14+ });
15+}
+59−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { JobLogText } from "@g1t/contracts";
5+
6+import { archiveFiles, blocks, fileName, highlight, searchLog } from "./log-lines.ts";
7+
8+const LOG = ["Job: test", "##[group]Run cargo test", "cargo test --locked", "##[endgroup]", "test greet ... FAILED", "##[error]Process completed with exit code 101."].join("\n");
9+
10+test("a log reads as lines and folded groups, numbered past the group markers", () => {
11+ const parsed = blocks(LOG);
12+ assert.equal(parsed.length, 4);
13+ assert.deepEqual(parsed[1], { kind: "group", title: "Run cargo test", lines: [{ line: { kind: "text", text: "cargo test --locked" }, number: 2 }] });
14+ assert.deepEqual(parsed[3], { kind: "line", line: { kind: "error", text: "Process completed with exit code 101." }, number: 4 });
15+});
16+
17+test("search finds lines in any case, inside folded groups too", () => {
18+ assert.deepEqual(
19+ searchLog(LOG, "CARGO TEST").map((found) => found.number),
20+ [2],
21+ );
22+ assert.deepEqual(
23+ searchLog(LOG, "test").map((found) => found.number),
24+ [1, 2, 3],
25+ );
26+ assert.deepEqual(searchLog(LOG, " "), []);
27+ // The group's title is not a line of its own.
28+ assert.deepEqual(searchLog(LOG, "Run cargo"), []);
29+});
30+
31+test("matches are marked where they fall", () => {
32+ assert.deepEqual(highlight("Test a test", "test"), [
33+ { text: "Test", match: true },
34+ { text: " a ", match: false },
35+ { text: "test", match: true },
36+ ]);
37+ assert.deepEqual(highlight("plain", ""), [{ text: "plain", match: false }]);
38+});
39+
40+test("a run's archive has each job whole and a file per step, named as the API names them", () => {
41+ const job = (name: string, chunks: [number, string][], omitted = false): JobLogText => ({
42+ jobId: "job_1",
43+ name,
44+ steps: [{ number: 1, name: "Run cargo test", status: "completed", conclusion: "success", startedAt: null, finishedAt: null }],
45+ chunks: chunks.map(([step, text], index) => ({ seq: index + 1, step, text })),
46+ done: true,
47+ omitted,
48+ });
49+ const files = archiveFiles([job("test", [[0, "set up\n"], [1, "running\n"], [1, "ok\n"]]), job("deploy/x", [], true)]);
50+ assert.deepEqual(
51+ files.map((file) => file.path),
52+ ["1_test.txt", "test/0_Set up job.txt", "test/1_Run cargo test.txt", "2_deploy_x.txt"],
53+ );
54+ assert.equal(files[0]!.text, "set up\nrunning\nok\n");
55+ assert.equal(files[2]!.text, "running\nok\n");
56+ assert.match(files[3]!.text, /left out/);
57+ assert.equal(fileName("build / a:b"), "build _ a_b");
58+ assert.equal(fileName(".."), "job");
59+});
+116−0
1+/**
2+ * A job's log as GitHub shows one: lines, folded `##[group]`s, errors and
3+ * warnings marked; searching it; and the files of a run's log archive.
4+ */
5+
6+import type { JobLogText } from "@g1t/contracts";
7+
8+export type LineKind = "text" | "error" | "warning" | "notice" | "debug" | "command";
9+export type Line = { kind: LineKind; text: string };
10+export type Block =
11+ | { kind: "line"; line: Line; number: number }
12+ | { kind: "group"; title: string; lines: { line: Line; number: number }[] };
13+
14+export function classify(raw: string): Line | "group-end" | { group: string } {
15+ if (raw.startsWith("##[group]")) return { group: raw.slice(9) };
16+ if (raw.startsWith("##[endgroup]")) return "group-end";
17+ for (const kind of ["error", "warning", "notice", "debug"] as const) {
18+ if (raw.startsWith(`##[${kind}]`)) return { kind, text: raw.slice(kind.length + 4) };
19+ }
20+ if (raw.startsWith("[command]")) return { kind: "command", text: raw.slice(9) };
21+ return { kind: "text", text: raw };
22+}
23+
24+/** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */
25+export function blocks(text: string): Block[] {
26+ const out: Block[] = [];
27+ let group: Extract<Block, { kind: "group" }> | null = null;
28+ let number = 0;
29+ for (const raw of text.split("\n")) {
30+ if (raw === "" && number === 0) continue;
31+ const line = classify(raw);
32+ if (line === "group-end") {
33+ group = null;
34+ continue;
35+ }
36+ if ("group" in line) {
37+ group = { kind: "group", title: line.group, lines: [] };
38+ out.push(group);
39+ continue;
40+ }
41+ number += 1;
42+ if (group) group.lines.push({ line, number });
43+ else out.push({ kind: "line", line, number });
44+ }
45+ return out;
46+}
47+
48+/** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */
49+export function searchLog(text: string, query: string): { line: Line; number: number }[] {
50+ const wanted = query.trim().toLowerCase();
51+ if (!wanted) return [];
52+ const found: { line: Line; number: number }[] = [];
53+ for (const block of blocks(text)) {
54+ const lines = block.kind === "line" ? [{ line: block.line, number: block.number }] : block.lines;
55+ for (const entry of lines) {
56+ if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry);
57+ }
58+ }
59+ return found;
60+}
61+
62+/** `text` cut where `query` (any case) appears, each piece marked as a match or not. */
63+export function highlight(text: string, query: string): { text: string; match: boolean }[] {
64+ const wanted = query.trim().toLowerCase();
65+ if (!wanted) return [{ text, match: false }];
66+ const lower = text.toLowerCase();
67+ const out: { text: string; match: boolean }[] = [];
68+ let at = 0;
69+ for (let found = lower.indexOf(wanted); found !== -1; found = lower.indexOf(wanted, at)) {
70+ if (found > at) out.push({ text: text.slice(at, found), match: false });
71+ out.push({ text: text.slice(found, found + wanted.length), match: true });
72+ at = found + wanted.length;
73+ }
74+ if (at < text.length) out.push({ text: text.slice(at), match: false });
75+ return out;
76+}
77+
78+/** A name safe as a file name in an archive, as the API's archive names them. */
79+export function fileName(name: string): string {
80+ const cleaned = Array.from(name)
81+ .map((c) => (/[/\\:*?"<>|]/.test(c) || c.charCodeAt(0) < 32 ? "_" : c))
82+ .slice(0, 100)
83+ .join("")
84+ .trim()
85+ .replace(/^\.+|\.+$/g, "");
86+ return cleaned || "job";
87+}
88+
89+/** A job's whole log as one text. */
90+export function jobText(job: JobLogText): string {
91+ if (job.omitted) return "This job's log was left out: the run's logs are larger than one download holds. Download it on its own.\n";
92+ return job.chunks.map((chunk) => chunk.text).join("");
93+}
94+
95+/**
96+ * The files of a run's log archive: `{n}_{job}.txt` with each job's whole
97+ * log, and `{job}/{step}_{step name}.txt` for each of its steps.
98+ */
99+export function archiveFiles(jobs: JobLogText[]): { path: string; text: string }[] {
100+ const files: { path: string; text: string }[] = [];
101+ jobs.forEach((job, index) => {
102+ const name = fileName(job.name);
103+ files.push({ path: `${index + 1}_${name}.txt`, text: jobText(job) });
104+ if (job.omitted) return;
105+ const steps = [...new Set(job.chunks.map((chunk) => chunk.step))].sort((a, b) => a - b);
106+ for (const step of steps) {
107+ const title = step === 0 ? "Set up job" : (job.steps.find((s) => s.number === step)?.name ?? `Step ${step}`);
108+ const text = job.chunks
109+ .filter((chunk) => chunk.step === step)
110+ .map((chunk) => chunk.text)
111+ .join("");
112+ files.push({ path: `${name}/${step}_${fileName(title)}.txt`, text });
113+ }
114+ });
115+ return files;
116+}
+2−0
178178 route("actions/runs/:id", "routes/repo/actions-run.tsx"),
179179 route("actions/runs/:id/artifacts/:name", "routes/repo/actions-artifact.ts"),
180180 route("actions/jobs/:job/log", "routes/repo/actions-log.ts"),
181+ route("actions/jobs/:job/log.txt", "routes/repo/actions-job-log-download.ts"),
182+ route("actions/runs/:id/logs.zip", "routes/repo/actions-logs-download.ts"),
181183 route("deployments", "routes/repo/deployments.tsx"),
182184 route("deployments/:id", "routes/repo/deployment.tsx"),
183185 // Security: an overview, then a page for each part. Old links to
+17−0
1+/**
2+ * One job's whole log as plain text (`actions/jobs/:job/log.txt`), any
3+ * attempt's, by the id the run page gives the job. For anyone who can see
4+ * the run.
5+ */
6+import type { Route } from "./+types/actions-job-log-download";
7+import { fileName, jobText } from "../../lib/log-lines";
8+import { attachment, refused } from "../../lib/log-download.server";
9+import { actions } from "../../lib/services.server";
10+import { getViewer } from "../../lib/session.server";
11+
12+export async function loader({ params, context }: Route.LoaderArgs) {
13+ const repo = { namespace: params.owner, name: params.repo };
14+ const log = await actions.jobLogText(repo, getViewer(context), params.job);
15+ if (!log.ok) return refused(log.error.code === "not_found" ? 404 : 400, log.error.message);
16+ return attachment(jobText(log.value), "text/plain; charset=utf-8", `${fileName(log.value.name)}.txt`);
17+}
+23−0
1+/**
2+ * Every job's log of a run's attempt as one zip
3+ * (`actions/runs/:id/logs.zip?attempt=`), laid out as the API's archive is:
4+ * `{n}_{job}.txt` whole, and `{job}/{step}_{step name}.txt`. For anyone who
5+ * can see the run.
6+ */
7+import type { Route } from "./+types/actions-logs-download";
8+import { archiveFiles } from "../../lib/log-lines";
9+import { attachment, refused } from "../../lib/log-download.server";
10+import { actions } from "../../lib/services.server";
11+import { getViewer } from "../../lib/session.server";
12+import { zip } from "../../lib/zip";
13+
14+export async function loader({ params, context, request }: Route.LoaderArgs) {
15+ const repo = { namespace: params.owner, name: params.repo };
16+ const asked = Number(new URL(request.url).searchParams.get("attempt") ?? "");
17+ const attempt = Number.isInteger(asked) && asked > 0 ? asked : undefined;
18+ const logs = await actions.runLogs(repo, getViewer(context), params.id, attempt);
19+ if (!logs.ok) return refused(logs.error.code === "not_found" ? 404 : 400, logs.error.message);
20+ const encoder = new TextEncoder();
21+ const archive = await zip(archiveFiles(logs.value).map((file) => ({ path: file.path, data: encoder.encode(file.text) })));
22+ return attachment(archive, "application/zip", attempt ? `logs_${params.id}_attempt_${attempt}.zip` : `logs_${params.id}.zip`);
23+}
+361−48
1−import { AlertTriangle, Check, ChevronRight, Cloud, Download, GitBranch, GitCommitHorizontal, Hourglass, Info, Package, Play, RotateCw, ServerCog, ShieldAlert, Square, Trash2, Users, X, XCircle } from "lucide-react";
2−import { type ReactNode } from "react";
1+import {
2+ AlertTriangle,
3+ Bug,
4+ Check,
5+ ChevronDown,
6+ ChevronRight,
7+ Cloud,
8+ Download,
9+ FileText,
10+ GitBranch,
11+ GitCommitHorizontal,
12+ History,
13+ Hourglass,
14+ Info,
15+ Package,
16+ Play,
17+ RotateCw,
18+ Search,
19+ ServerCog,
20+ ShieldAlert,
21+ Square,
22+ Trash2,
23+ Users,
24+ X,
25+ XCircle,
26+} from "lucide-react";
27+import { type ReactNode, useEffect, useRef, useState } from "react";
328 import { Form, Link, useLoaderData, useSearchParams } from "react-router";
429
5−import type { Annotation, EnvironmentReviewer, Job, PendingDeployment, RunApproval, StepState } from "@g1t/contracts";
30+import type { Annotation, EnvironmentReviewer, Job, JobSummary, PendingDeployment, RunApproval, RunAttempt, StepState } from "@g1t/contracts";
631
732 import type { Route } from "./+types/actions-run";
833 import { page } from "../../lib/meta";
934 import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
10−import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
35+import { Markdown } from "../../components/markdown";
36+import { Button, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
37+import { CheckboxOption } from "../../components/ui/checkbox";
38+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog";
39+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
1140 import { Hint } from "../../components/ui/hint";
41+import { searchLog } from "../../lib/log-lines";
1242 import { listArtifacts } from "../../lib/artifacts.server";
1343 import { expiresIn, formatBytes } from "../../lib/artifacts";
1444 import { actions } from "../../lib/services.server";
2151 return page(args, { title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` });
2252 }
2353
24−export async function loader({ params, context }: Route.LoaderArgs) {
54+export async function loader({ params, context, request }: Route.LoaderArgs) {
2555 const viewer = getViewer(context);
2656 const repo = { namespace: params.owner, name: params.repo };
27− const [detail, artifacts] = await Promise.all([
28− actions.run(repo, viewer, params.id).then(unwrap),
57+ // An earlier attempt, when one is asked for.
58+ const asked = Number(new URL(request.url).searchParams.get("attempt") ?? "");
59+ const attempt = Number.isInteger(asked) && asked > 0 ? asked : undefined;
60+ const [detail, artifacts, summaries] = await Promise.all([
61+ actions.run(repo, viewer, params.id, attempt).then(unwrap),
2962 listArtifacts(repo, viewer, params.id).catch(() => []),
63+ actions
64+ .summaries(repo, viewer, params.id, attempt)
65+ .then((found) => (found.ok ? found.value : []))
66+ .catch((): JobSummary[] => []),
3067 ]);
3168 // Cancelling and re-running need Write.
3269 return {
3370 detail,
3471 artifacts,
72+ summaries,
3573 member: (await accessTo(context, params)).can.run,
3674 // The runners page is the workspace owners'.
3775 runnersPage: roleIn(viewer, params.owner) === "owner",
66104 const deleted = await actions.deleteArtifact(user, repo, Number(form.get("artifact")));
67105 return deleted.ok ? {} : { error: deleted.error.message };
68106 }
107+ // Debug logging, for a re-run that asks for it.
108+ const debug = form.get("debug") === "on";
109+ const job = String(form.get("job") ?? "") || undefined;
69110 const done =
70111 intent === "approve-run"
71112 ? await actions.approveRun(user, repo, params.id)
72− : intent === "cancel"
73− ? await actions.cancel(user, repo, params.id)
74− : await actions.rerun(user, repo, params.id, intent === "rerun-failed");
113+ : intent === "cancel" || intent === "force-cancel"
114+ ? await actions.cancel(user, repo, params.id, intent === "force-cancel")
115+ : await actions.rerun(user, repo, params.id, intent === "rerun-failed", { job: intent === "rerun-job" ? job : undefined, debug });
75116 return done.ok ? {} : { error: done.error.message };
76117 }
77118
81122 notice: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
82123 };
83124
84−function StepRow({ step, text, defaultOpen }: { step: StepState; text: string | undefined; defaultOpen: boolean }) {
125+function StepRow({
126+ step,
127+ text,
128+ defaultOpen,
129+ query = "",
130+ matches = 0,
131+}: {
132+ step: StepState;
133+ text: string | undefined;
134+ defaultOpen: boolean;
135+ /** A search of the job's log: only its matching lines are shown. */
136+ query?: string;
137+ matches?: number;
138+}) {
85139 return (
86140 <details className="group border-t border-line first:border-t-0" open={defaultOpen}>
87141 <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40">
88142 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
89143 <StatusIcon status={step.status} conclusion={step.conclusion} size={14} />
90144 <span className={`min-w-0 truncate ${step.conclusion === "skipped" ? "text-faint" : ""}`}>{step.name}</span>
145+ {query && (
146+ <span className="shrink-0 rounded-full bg-accent/15 px-1.5 text-xs text-accent">
147+ {matches} {matches === 1 ? "line" : "lines"}
148+ </span>
149+ )}
91150 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(step.startedAt, step.finishedAt)}</span>
92151 </summary>
93152 <div className="border-t border-line bg-bg/60">
94153 {text === undefined ? (
95154 <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output yet."}</p>
96155 ) : (
97− <LogText text={text} />
156+ <LogText text={text} query={query} />
98157 )}
99158 </div>
100159 </details>
101160 );
102161 }
103162
163+/**
164+ * Runs again, after asking whether with debug logging: the whole run, its
165+ * failed jobs, or one job (with the jobs that need it).
166+ */
167+function RerunDialog({
168+ intent,
169+ job,
170+ title,
171+ description,
172+ children,
173+ busy,
174+}: {
175+ intent: "rerun" | "rerun-failed" | "rerun-job";
176+ job?: string;
177+ title: string;
178+ description: string;
179+ /** What opens it. */
180+ children: (open: () => void) => ReactNode;
181+ busy: boolean;
182+}) {
183+ const [open, setOpen] = useState(false);
184+ // Open, saying it is starting, until the new attempt shows or the error does.
185+ const running = usePending({ intent });
186+ const was = useRef(false);
187+ useEffect(() => {
188+ if (was.current && !running) setOpen(false);
189+ was.current = running;
190+ }, [running]);
191+ return (
192+ <>
193+ {children(() => setOpen(true))}
194+ <Dialog open={open} onOpenChange={setOpen}>
195+ <DialogContent>
196+ <Form method="post" preventScrollReset className="grid gap-4">
197+ <DialogHeader>
198+ <DialogTitle>{title}</DialogTitle>
199+ <DialogDescription>{description}</DialogDescription>
200+ </DialogHeader>
201+ {job && <input type="hidden" name="job" value={job} />}
202+ <CheckboxOption
203+ name="debug"
204+ label="Enable debug logging"
205+ description="Sets RUNNER_DEBUG=1 and ACTIONS_STEP_DEBUG: ::debug:: lines are shown, and how each step's if: read."
206+ />
207+ <DialogFooter>
208+ <Button type="button" variant="quiet" onClick={() => setOpen(false)}>
209+ Cancel
210+ </Button>
211+ <SubmitButton name="intent" value={intent} variant="accent" disabled={busy} pending="Re-running…">
212+ <RotateCw size={13} />
213+ {title}
214+ </SubmitButton>
215+ </DialogFooter>
216+ </Form>
217+ </DialogContent>
218+ </Dialog>
219+ </>
220+ );
221+}
222+
223+/** A link or menu button that sits beside the run's quiet buttons, at their size. */
224+const QUIET_LINK =
225+ "inline-flex items-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg";
226+
227+/** The run's attempts, newest first, each a link to how it went. */
228+function AttemptPicker({ attempts, shown }: { attempts: RunAttempt[]; shown: number }) {
229+ const latest = attempts[attempts.length - 1]?.attempt ?? shown;
230+ return (
231+ <DropdownMenu>
232+ <DropdownMenuTrigger asChild>
233+ <button type="button" className={QUIET_LINK}>
234+ <History size={13} />
235+ Attempt #{shown}
236+ <ChevronDown size={13} />
237+ </button>
238+ </DropdownMenuTrigger>
239+ <DropdownMenuContent align="end" className="min-w-64">
240+ {[...attempts].reverse().map((attempt) => (
241+ <DropdownMenuItem key={attempt.attempt} asChild>
242+ <Link
243+ to={attempt.attempt === latest ? "?" : `?attempt=${attempt.attempt}`}
244+ preventScrollReset
245+ className={`flex items-center gap-2 ${attempt.attempt === shown ? "text-fg" : ""}`}
246+ >
247+ <StatusIcon status={attempt.status} conclusion={attempt.conclusion} size={14} />
248+ <span className="font-medium">Attempt #{attempt.attempt}</span>
249+ {attempt.debug && <Bug size={12} className="text-faint" aria-label="Debug logging" />}
250+ <span className="ml-auto truncate text-xs text-faint">
251+ {attempt.actor && `${attempt.actor} · `}
252+ {attempt.startedAt ? <TimeAgo at={attempt.startedAt} /> : "not started"}
253+ </span>
254+ </Link>
255+ </DropdownMenuItem>
256+ ))}
257+ </DropdownMenuContent>
258+ </DropdownMenu>
259+ );
260+}
261+
262+/** What the jobs' steps wrote to $GITHUB_STEP_SUMMARY, a card per job. */
263+function Summaries({ summaries, repo }: { summaries: JobSummary[]; repo: { namespace: string; name: string } }) {
264+ if (summaries.length === 0) return null;
265+ return (
266+ <section aria-label="Job summaries" className="space-y-4">
267+ {summaries.map((summary) => (
268+ <article key={summary.jobId} className="overflow-hidden rounded-xl border border-line bg-surface">
269+ <header className="flex items-center gap-2 border-b border-line px-4 py-2.5 text-sm">
270+ <FileText size={14} className="text-muted" />
271+ <span className="font-medium">{summary.name}</span>
272+ <span className="text-faint">summary</span>
273+ </header>
274+ <div className="space-y-4 px-5 py-4">
275+ {summary.steps.map((step) => (
276+ <Markdown key={step.step} source={step.markdown} repo={repo} />
277+ ))}
278+ </div>
279+ </article>
280+ ))}
281+ </section>
282+ );
283+}
284+
104285 /** Why a job did not run as asked, with "Integrations" linked where it says so. */
105286 function Reason({ text, workspace }: { text: string; workspace: string }) {
106287 const [before, after] = text.split("Integrations");
154335 );
155336 }
156337
157−function JobView({ job, base }: { job: Job; base: string }) {
338+function JobView({ job, base, rerun }: { job: Job; base: string; rerun: ReactNode }) {
158339 const live = job.status !== "completed";
159340 const log = useJobLog(`${base}/actions/jobs/${job.id}/log`, live);
341+ const [query, setQuery] = useState("");
160342 const failed = job.steps.find((s) => s.conclusion === "failure");
161343 const running = job.steps.find((s) => s.status === "in_progress");
344+ const searching = query.trim().length > 0;
345+ const setUp: StepState = {
346+ number: 0,
347+ name: "Set up job",
348+ status: job.startedAt ? "completed" : "queued",
349+ conclusion: job.startedAt ? "success" : null,
350+ startedAt: job.startedAt,
351+ finishedAt: job.startedAt,
352+ };
353+ // While searching, the steps with a match, each with how many.
354+ const steps = [setUp, ...job.steps]
355+ .map((step) => ({ step, matches: searching ? searchLog(log.get(step.number) ?? "", query).length : 0 }))
356+ .filter(({ matches }) => !searching || matches > 0);
357+ const total = steps.reduce((sum, { matches }) => sum + matches, 0);
162358 return (
163359 <section className="min-w-0 space-y-4">
164360 <div className="flex flex-wrap items-center gap-3">
165361 <StatusIcon status={job.status} conclusion={job.conclusion} of="job" environment={job.environment} size={18} />
166362 <h3 className="text-base font-semibold">{job.name}</h3>
167363 <span className="text-sm text-muted">
168− {standingWord({ ...job, of: "job" })}
364+ {job.cancelling ? "Cancelling: running its cleanup steps" : standingWord({ ...job, of: "job" })}
169365 {job.startedAt && ` · ${duration(job.startedAt, job.finishedAt)}`}
170366 </span>
171− <RanOn job={job} workspace={base.split("/")[1]!} />
367+ <span className="ml-auto flex items-center gap-2">
368+ {rerun}
369+ {job.startedAt && (
370+ <Hint label="Download this job's log">
371+ <a
372+ href={`${base}/actions/jobs/${job.id}/log.txt`}
373+ aria-label={`Download the log of ${job.name}`}
374+ className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg"
375+ >
376+ <Download size={13} />
377+ </a>
378+ </Hint>
379+ )}
380+ <RanOn job={job} workspace={base.split("/")[1]!} />
381+ </span>
172382 </div>
173383 {job.reason && <Reason text={job.reason} workspace={base.split("/")[1]} />}
174384 {job.annotations.length > 0 && (
190400 ))}
191401 </ul>
192402 )}
403+ {job.startedAt && (
404+ <label className="flex items-center gap-2 rounded-lg border border-line bg-surface px-3 py-1.5 text-sm focus-within:border-accent-dim">
405+ <Search size={14} className="shrink-0 text-faint" />
406+ <input
407+ type="search"
408+ value={query}
409+ onChange={(event) => setQuery(event.target.value)}
410+ placeholder="Search logs"
411+ aria-label={`Search the log of ${job.name}`}
412+ className="min-w-0 grow bg-transparent outline-none placeholder:text-faint"
413+ />
414+ {searching && (
415+ <span className="shrink-0 text-xs text-muted" aria-live="polite">
416+ {total} {total === 1 ? "line" : "lines"}
417+ </span>
418+ )}
419+ </label>
420+ )}
193421 <div className="overflow-hidden rounded-xl border border-line bg-surface">
194− <StepRow
195− step={{ number: 0, name: "Set up job", status: job.startedAt ? "completed" : "queued", conclusion: job.startedAt ? "success" : null, startedAt: job.startedAt, finishedAt: job.startedAt }}
196− text={log.get(0)}
197− defaultOpen={false}
198− />
199− {job.steps.map((step) => (
422+ {searching && steps.length === 0 && <p className="px-4 py-3 text-sm text-muted">No line of this job's log holds “{query.trim()}”.</p>}
423+ {steps.map(({ step, matches }) => (
200424 <StepRow
201− key={step.number}
425+ // Searching opens every step with a match; clearing it puts them back.
426+ key={`${step.number}:${searching}`}
202427 step={step}
203428 text={log.get(step.number)}
204− defaultOpen={step.number === (failed ?? running)?.number}
429+ defaultOpen={searching || (step.number !== 0 && step.number === (failed ?? running)?.number)}
430+ query={searching ? query : ""}
431+ matches={matches}
205432 />
206433 ))}
207434 </div>
430657 }
431658
432659 export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) {
433− const { detail, artifacts, member } = loaderData;
660+ const { detail, artifacts, member, summaries } = loaderData;
434661 const { run, jobs, notes } = detail;
435662 const deployments = detail.pendingDeployments ?? [];
663+ const attempts = detail.attempts ?? [];
664+ const latest = attempts.length === 0 || run.attempt === attempts[attempts.length - 1]!.attempt;
436665 const base = `/${params.owner}/${params.repo}`;
437666 const [search] = useSearchParams();
438667 // One of the run's buttons is working: the others wait for it.
439668 const busy = usePending();
440669 const live = run.status !== "completed";
670+ // Cancelled, while its jobs run their cleanup steps.
671+ const cancelling = live && run.conclusion === "cancelled";
672+ // Re-running is for the latest attempt of a finished run.
673+ const canRerun = member && !run.error && !live && latest;
674+ const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`;
441675 // Waiting on a person needs no quick refresh; a running job does.
442676 useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : 2500);
443677
461695 <span className="min-w-0 truncate">{run.title || run.name}</span>
462696 <span className="font-normal text-muted">#{run.number}</span>
463697 </h2>
464− {member && !run.error && (
465− <Form method="post" className="flex gap-2">
466− {live ? (
467− <SubmitButton name="intent" value="cancel" variant="quiet" disabled={busy} pending="Cancelling…">
468− <Square size={13} />
469− Cancel run
470− </SubmitButton>
471− ) : (
472− <>
473− {anyFailed && (
474− <SubmitButton name="intent" value="rerun-failed" variant="quiet" disabled={busy} pending="Re-running…">
475− <RotateCw size={13} />
476− Re-run failed jobs
698+ <div className="flex flex-wrap items-center gap-2">
699+ {attempts.length > 1 && <AttemptPicker attempts={attempts} shown={run.attempt} />}
700+ {jobs.some((job) => job.startedAt) && (
701+ <Hint label={latest ? "Every job's log, as a zip" : `Attempt #${run.attempt}'s logs, as a zip`}>
702+ <a href={logsUrl} className={QUIET_LINK}>
703+ <Download size={13} />
704+ Download logs
705+ </a>
706+ </Hint>
707+ )}
708+ {member && !run.error && live && (
709+ <Form method="post" className="flex gap-2">
710+ {cancelling ? (
711+ <Hint label="Stop its jobs now, without waiting for their cleanup steps">
712+ <SubmitButton name="intent" value="force-cancel" variant="danger" disabled={busy} pending="Stopping…">
713+ <Square size={13} />
714+ Force cancel
477715 </SubmitButton>
478− )}
479− <SubmitButton name="intent" value="rerun" variant="quiet" disabled={busy} pending="Re-running…">
716+ </Hint>
717+ ) : (
718+ <SubmitButton name="intent" value="cancel" variant="quiet" disabled={busy} pending="Cancelling…">
719+ <Square size={13} />
720+ Cancel run
721+ </SubmitButton>
722+ )}
723+ </Form>
724+ )}
725+ {canRerun && anyFailed && (
726+ <RerunDialog
727+ intent="rerun-failed"
728+ title="Re-run failed jobs"
729+ description="The jobs that did not succeed run again as a new attempt, with every job that needs them. The others keep how they ended."
730+ busy={busy}
731+ >
732+ {(open) => (
733+ <Button type="button" variant="quiet" disabled={busy} onClick={open}>
480734 <RotateCw size={13} />
735+ Re-run failed jobs
736+ </Button>
737+ )}
738+ </RerunDialog>
739+ )}
740+ {canRerun && (
741+ <RerunDialog
742+ intent="rerun"
743+ title="Re-run all jobs"
744+ description="Every job runs again as a new attempt. This attempt stays here, with its logs."
745+ busy={busy}
746+ >
747+ {(open) => (
748+ <Button type="button" variant="quiet" disabled={busy} onClick={open}>
749+ <RotateCw size={13} />
481750 Re-run all jobs
482− </SubmitButton>
483− </>
484− )}
485− </Form>
486− )}
751+ </Button>
752+ )}
753+ </RerunDialog>
754+ )}
755+ </div>
487756 </div>
488757 <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
489− <span>{runWord(run.status, deployments) ?? standingWord(run)}</span>
758+ <span>{runWord(run.status, deployments) ?? (cancelling ? "Cancelling" : standingWord(run))}</span>
490759 <span className="inline-flex items-center gap-1 font-mono text-xs">
491760 <GitBranch size={12} />
492761 {shortRef(run.ref)}
505774 {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} />
506775 </span>
507776 {run.startedAt && <span className="font-mono text-xs">{duration(run.startedAt, run.finishedAt)}</span>}
508− {run.attempt > 1 && <span>Attempt {run.attempt}</span>}
777+ {run.attempt > 1 && attempts.length <= 1 && <span>Attempt {run.attempt}</span>}
778+ {cancelling && <span className="text-warn">Its jobs are running their cleanup steps</span>}
509779 {detail.approval?.state === "approved" && detail.approval.approvedBy && <span>Approved by {detail.approval.approvedBy}</span>}
510780 </p>
511781 </header>
512782
513783 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
784+ {!latest && (
785+ <p className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted">
786+ <History size={15} className="shrink-0" />
787+ <span>
788+ This is attempt #{run.attempt} of {attempts.length}, as it ended.
789+ </span>
790+ <Link to="?" className="text-accent hover:underline">
791+ See the latest attempt
792+ </Link>
793+ </p>
794+ )}
514795 {detail.approval?.state === "required" && <ApprovalPanel approval={detail.approval} member={member} busy={busy} />}
515796 {live && held(deployments) && <DeploymentsPanel deployments={deployments} busy={busy} />}
516797 {run.error && (
520801 </div>
521802 )}
522803 <Notes notes={notes} />
804+ <Summaries summaries={summaries} repo={{ namespace: params.owner, name: params.repo }} />
523805 {artifacts.length > 0 && (
524806 <section className="rounded-xl border border-line bg-surface p-4">
525807 <h3 className="flex items-center gap-2 text-sm font-medium">
575857 {jobs.map((job) => (
576858 <Link
577859 key={job.id}
578− to={`?job=${job.id}`}
860+ to={latest ? `?job=${job.id}` : `?attempt=${run.attempt}&job=${job.id}`}
579861 preventScrollReset
580862 className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
581863 job.id === selected?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
588870 ))}
589871 </nav>
590872 {/* Run again, a job keeps its id but its log starts afresh. */}
591− {selected && <JobView key={`${selected.id}:${run.attempt}`} job={selected} base={base} />}
873+ {selected && (
874+ <JobView
875+ key={`${selected.id}:${run.attempt}`}
876+ job={selected}
877+ base={base}
878+ rerun={
879+ canRerun && selected.status === "completed" ? (
880+ <RerunDialog
881+ intent="rerun-job"
882+ job={selected.id}
883+ title="Re-run this job"
884+ description={`${selected.name} runs again as a new attempt, with every job that needs it. A job of a matrix runs again with the rest of its matrix.`}
885+ busy={busy}
886+ >
887+ {(open) => (
888+ <Hint label="Re-run this job">
889+ <button
890+ type="button"
891+ aria-label={`Re-run ${selected.name}`}
892+ disabled={busy}
893+ onClick={open}
894+ className="inline-flex items-center rounded-md p-1.5 text-muted ring-1 ring-line hover:text-fg disabled:opacity-50"
895+ >
896+ <RotateCw size={13} />
897+ </button>
898+ </Hint>
899+ )}
900+ </RerunDialog>
901+ ) : null
902+ }
903+ />
904+ )}
592905 </div>
593906 )}
594907 </div>
+48−3
103103 selfHosted?: boolean;
104104 /** The self-hosted runner that took it, by name. */
105105 runner?: string | null;
106+ /** Cancelled, and running its `if: always()` and `cancelled()` steps and post steps before it ends. */
107+ cancelling?: boolean;
108+};
109+
110+/** One attempt of a run: the first, or a re-run. */
111+export type RunAttempt = {
112+ attempt: number;
113+ status: RunStatus;
114+ conclusion: Conclusion | null;
115+ /** Who started it: whoever caused the run, then whoever re-ran it. */
116+ actor: string | null;
117+ /** It ran with debug logging. */
118+ debug: boolean;
119+ startedAt: string | null;
120+ finishedAt: string | null;
106121 };
107122
123+/** What a job's steps wrote to `$GITHUB_STEP_SUMMARY`, in Markdown, masked. */
124+export type JobSummary = { jobId: string; name: string; steps: { step: number; markdown: string }[] };
125+
126+/** A job's whole log, to download. `omitted`: left out of a run's logs that grew too large. */
127+export type JobLogText = {
128+ jobId: string;
129+ name: string;
130+ steps: StepState[];
131+ chunks: LogChunk[];
132+ done: boolean;
133+ omitted?: boolean;
134+};
135+
108136 export type RunDetail = {
109137 run: WorkflowRun;
110138 jobs: Job[];
113141 approval?: RunApproval | null;
114142 /** The environments whose protection rules hold its jobs, this attempt. */
115143 pendingDeployments?: PendingDeployment[];
144+ /** Every attempt, oldest first, the one shown (`run.attempt`) included. */
145+ attempts?: RunAttempt[];
116146 };
117147
118148 export type RunApproval = {
279309 export interface ActionsApi {
280310 workflows(repo: RepoPath, viewer: Viewer): Promise<Result<Workflow[]>>;
281311 runs(repo: RepoPath, viewer: Viewer, filter?: RunsFilter): Promise<Result<WorkflowRun[]>>;
282− run(repo: RepoPath, viewer: Viewer, id: string): Promise<Result<RunDetail>>;
312+ /** The latest attempt, or an earlier one. */
313+ run(repo: RepoPath, viewer: Viewer, id: string, attempt?: number): Promise<Result<RunDetail>>;
283314 logs(repo: RepoPath, viewer: Viewer, job: string, after?: number): Promise<Result<JobLog>>;
315+ /** The job summaries of an attempt (the latest by default), jobs without one left out. */
316+ summaries(repo: RepoPath, viewer: Viewer, id: string, attempt?: number): Promise<Result<JobSummary[]>>;
317+ /** A job's whole log, any attempt's, by the id the run gave the job. */
318+ jobLogText(repo: RepoPath, viewer: Viewer, job: string): Promise<Result<JobLogText>>;
319+ /** Every job's whole log for an attempt, to download as one archive. */
320+ runLogs(repo: RepoPath, viewer: Viewer, id: string, attempt?: number): Promise<Result<JobLogText[]>>;
284321 dispatch(
285322 actor: User,
286323 repo: RepoPath,
288325 ref: string | undefined,
289326 inputs: Record<string, unknown>,
290327 ): Promise<Result<WorkflowRun>>;
291− cancel(actor: User, repo: RepoPath, id: string): Promise<Result<WorkflowRun>>;
292− rerun(actor: User, repo: RepoPath, id: string, failedOnly?: boolean): Promise<Result<WorkflowRun>>;
328+ /** Running jobs clean up first; `force` (or cancelling a run that is cancelling) stops them outright. */
329+ cancel(actor: User, repo: RepoPath, id: string, force?: boolean): Promise<Result<WorkflowRun>>;
330+ /** A new attempt: every job, the failed ones, or `job` and what needs it; `debug` for debug logging. */
331+ rerun(
332+ actor: User,
333+ repo: RepoPath,
334+ id: string,
335+ failedOnly?: boolean,
336+ options?: { job?: string; debug?: boolean },
337+ ): Promise<Result<WorkflowRun>>;
293338 setWorkflowEnabled(actor: User, repo: RepoPath, workflow: string, enabled: boolean): Promise<Result<Workflow>>;
294339 settings(actor: User, owner: SettingsOwner, kind: SettingKindFilter): Promise<Result<Setting[]>>;
295340 /** `value` null keeps an existing entry's default value. */
+7−3
761761 return {
762762 workflows: (repo, viewer) => call("workflows", { repo, viewer }),
763763 runs: (repo, viewer, filter = {}) => call("runs", { repo, viewer, ...filter }),
764− run: (repo, viewer, id) => call("run", { repo, viewer, id }),
764+ run: (repo, viewer, id, attempt) => call("run", { repo, viewer, id, attempt: attempt ?? null }),
765765 logs: (repo, viewer, job, after = 0) => call("logs", { repo, viewer, job, after }),
766+ summaries: (repo, viewer, id, attempt) => call("summaries", { repo, viewer, id, attempt: attempt ?? null }),
767+ jobLogText: (repo, viewer, job) => call("job_log_text", { repo, viewer, job }),
768+ runLogs: (repo, viewer, id, attempt) => call("run_logs", { repo, viewer, id, attempt: attempt ?? null }),
766769 dispatch: (actor, repo, workflow, ref, inputs) => call("dispatch", { actor, repo, workflow, ref, inputs }),
767− cancel: (actor, repo, id) => call("cancel", { actor, repo, id }),
768− rerun: (actor, repo, id, failedOnly = false) => call("rerun", { actor, repo, id, failed_only: failedOnly }),
770+ cancel: (actor, repo, id, force = false) => call("cancel", { actor, repo, id, force }),
771+ rerun: (actor, repo, id, failedOnly = false, options = {}) =>
772+ call("rerun", { actor, repo, id, failed_only: failedOnly, job: options.job ?? null, debug: options.debug ?? false }),
769773 setWorkflowEnabled: (actor, repo, workflow, enabled) =>
770774 call("set_workflow_enabled", { actor, repo, workflow, enabled }),
771775 settings: (actor, owner, kind) => call("settings", { actor, ...owner, kind }),