g1t/apps/web/app/routes/repo/actions-run.tsx

232 lines10,580 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1import { AlertTriangle, ChevronRight, GitBranch, GitCommitHorizontal, Info, RotateCw, Square, XCircle } from "lucide-react";
2import { type ReactNode, useEffect } from "react";
3import { Form, Link, useNavigation, useRevalidator, useSearchParams } from "react-router";
4
5import type { Annotation, Job, StepState } from "@g1t/contracts";
6
7import type { Route } from "./+types/actions-run";
8import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
9import { Button, ErrorText, TimeAgo } from "../../components/ui";
10import { actions } from "../../lib/services.server";
11import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
12
13export function meta({ loaderData, params }: Route.MetaArgs) {
14 const run = loaderData?.detail.run;
15 return [{ title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` }];
16}
17
18export async function loader({ params, context }: Route.LoaderArgs) {
19 const viewer = getViewer(context);
20 const detail = unwrap(await actions.run({ namespace: params.owner, name: params.repo }, viewer, params.id));
21 return { detail, member: roleIn(viewer, params.owner) != null };
22}
23
24export async function action({ request, params, context }: Route.ActionArgs) {
25 assertSameOrigin(request);
26 const user = requireUser(context, request);
27 const repo = { namespace: params.owner, name: params.repo };
28 const intent = String((await request.formData()).get("intent"));
29 const done =
30 intent === "cancel"
31 ? await actions.cancel(user, repo, params.id)
32 : await actions.rerun(user, repo, params.id, intent === "rerun-failed");
33 return done.ok ? {} : { error: done.error.message };
34}
35
36const ANNOTATION_ICON: Record<Annotation["level"], ReactNode> = {
37 error: <XCircle size={14} className="mt-0.5 shrink-0 text-danger" />,
38 warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
39 notice: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
40};
41
42function StepRow({ step, text, defaultOpen }: { step: StepState; text: string | undefined; defaultOpen: boolean }) {
43 return (
44 <details className="group border-t border-line first:border-t-0" open={defaultOpen}>
45 <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40">
46 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
47 <StatusIcon status={step.status} conclusion={step.conclusion} size={14} />
48 <span className={`min-w-0 truncate ${step.conclusion === "skipped" ? "text-faint" : ""}`}>{step.name}</span>
49 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(step.startedAt, step.finishedAt)}</span>
50 </summary>
51 <div className="border-t border-line bg-bg/60">
52 {text === undefined ? (
53 <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output yet."}</p>
54 ) : (
55 <LogText text={text} />
56 )}
57 </div>
58 </details>
59 );
60}
61
62function JobView({ job, base }: { job: Job; base: string }) {
63 const live = job.status !== "completed";
64 const log = useJobLog(`${base}/actions/jobs/${job.id}/log`, live);
65 const failed = job.steps.find((s) => s.conclusion === "failure");
66 const running = job.steps.find((s) => s.status === "in_progress");
67 return (
68 <section className="min-w-0 space-y-4">
69 <div className="flex flex-wrap items-center gap-3">
70 <StatusIcon status={job.status} conclusion={job.conclusion} size={18} />
71 <h3 className="text-base font-semibold">{job.name}</h3>
72 <span className="text-sm text-muted">
73 {standingWord(job)}
74 {job.startedAt && ` · ${duration(job.startedAt, job.finishedAt)}`}
75 </span>
76 </div>
77 {job.reason && <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">{job.reason}</p>}
78 {job.annotations.length > 0 && (
79 <ul className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
80 {job.annotations.map((note, index) => (
81 <li key={index} className="flex gap-2">
82 {ANNOTATION_ICON[note.level]}
83 <span className="min-w-0">
84 {note.title && <span className="font-medium">{note.title}: </span>}
85 <span className="whitespace-pre-wrap">{note.message}</span>
86 {note.file && (
87 <span className="block font-mono text-xs text-faint">
88 {note.file}
89 {note.line != null && `:${note.line}`}
90 </span>
91 )}
92 </span>
93 </li>
94 ))}
95 </ul>
96 )}
97 <div className="overflow-hidden rounded-xl border border-line bg-surface">
98 <StepRow
99 step={{ number: 0, name: "Set up job", status: job.startedAt ? "completed" : "queued", conclusion: job.startedAt ? "success" : null, startedAt: job.startedAt, finishedAt: job.startedAt }}
100 text={log.get(0)}
101 defaultOpen={false}
102 />
103 {job.steps.map((step) => (
104 <StepRow
105 key={step.number}
106 step={step}
107 text={log.get(step.number)}
108 defaultOpen={step.number === (failed ?? running)?.number}
109 />
110 ))}
111 </div>
112 </section>
113 );
114}
115
116export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) {
117 const { detail, member } = loaderData;
118 const { run, jobs, notes } = detail;
119 const base = `/${params.owner}/${params.repo}`;
120 const [search] = useSearchParams();
121 const busy = useNavigation().state === "submitting";
122 const revalidator = useRevalidator();
123 const live = run.status !== "completed";
124 useEffect(() => {
125 if (!live) return;
126 const timer = setInterval(() => {
127 if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
128 }, 2500);
129 return () => clearInterval(timer);
130 }, [live, revalidator]);
131
132 // The job asked for, else one that failed, is running, or the first.
133 const selected =
134 jobs.find((job) => job.id === search.get("job")) ??
135 jobs.find((job) => job.conclusion === "failure" && job.steps.length > 0) ??
136 jobs.find((job) => job.status === "in_progress") ??
137 jobs[0];
138 const anyFailed = jobs.some((job) => job.conclusion === "failure" || job.conclusion === "cancelled");
139
140 return (
141 <div className="max-w-6xl space-y-6">
142 <header className="space-y-3">
143 <Link to={`${base}/actions?workflow=${run.workflowId}`} className="text-sm text-muted hover:text-fg">
144 {run.name}
145 </Link>
146 <div className="flex flex-wrap items-start justify-between gap-4">
147 <h2 className="flex min-w-0 items-center gap-2.5 text-xl font-semibold tracking-tight">
148 <StatusIcon status={run.status} conclusion={run.conclusion} size={20} />
149 <span className="min-w-0 truncate">{run.title || run.name}</span>
150 <span className="font-normal text-muted">#{run.number}</span>
151 </h2>
152 {member && !run.error && (
153 <Form method="post" className="flex gap-2">
154 {live ? (
155 <Button type="submit" name="intent" value="cancel" variant="quiet" disabled={busy}>
156 <Square size={13} />
157 Cancel run
158 </Button>
159 ) : (
160 <>
161 {anyFailed && (
162 <Button type="submit" name="intent" value="rerun-failed" variant="quiet" disabled={busy}>
163 <RotateCw size={13} />
164 Re-run failed jobs
165 </Button>
166 )}
167 <Button type="submit" name="intent" value="rerun" variant="quiet" disabled={busy}>
168 <RotateCw size={13} />
169 Re-run all jobs
170 </Button>
171 </>
172 )}
173 </Form>
174 )}
175 </div>
176 <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
177 <span>{standingWord(run)}</span>
178 <span className="inline-flex items-center gap-1 font-mono text-xs">
179 <GitBranch size={12} />
180 {shortRef(run.ref)}
181 </span>
182 <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
183 <GitCommitHorizontal size={12} />
184 {run.sha.slice(0, 7)}
185 </Link>
186 {run.pull != null && (
187 <Link to={`${base}/pull/${run.pull}`} className="hover:text-fg">
188 #{run.pull}
189 </Link>
190 )}
191 <span>
192 {run.event}
193 {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} />
194 </span>
195 {run.startedAt && <span className="font-mono text-xs">{duration(run.startedAt, run.finishedAt)}</span>}
196 {run.attempt > 1 && <span>Attempt {run.attempt}</span>}
197 </p>
198 </header>
199
200 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
201 {run.error && (
202 <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm">
203 <p className="font-medium text-danger">The workflow file could not be used</p>
204 <p className="mt-1 whitespace-pre-wrap text-fg/85">{run.error}</p>
205 </div>
206 )}
207 <Notes notes={notes} />
208
209 {jobs.length > 0 && (
210 <div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
211 <nav aria-label="Jobs" className="space-y-0.5 text-sm">
212 {jobs.map((job) => (
213 <Link
214 key={job.id}
215 to={`?job=${job.id}`}
216 preventScrollReset
217 className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
218 job.id === selected?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
219 }`}
220 >
221 <StatusIcon status={job.status} conclusion={job.conclusion} size={14} />
222 <span className="min-w-0 truncate">{job.name}</span>
223 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(job.startedAt, job.finishedAt)}</span>
224 </Link>
225 ))}
226 </nav>
227 {selected && <JobView key={selected.id} job={selected} base={base} />}
228 </div>
229 )}
230 </div>
231 );
232}