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

301 lines13,372 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.

Fast pages, required checks on the branch, self-hosted runners, honest incidents1import { AlertTriangle, ChevronRight, Cloud, Download, GitBranch, GitCommitHorizontal, Info, Package, RotateCw, ServerCog, Square, XCircle } from "lucide-react";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending2import { type ReactNode } from "react";
3import { Form, Link, useNavigation, useSearchParams } from "react-router";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs4
5import type { Annotation, Job, StepState } from "@g1t/contracts";
6
7import type { Route } from "./+types/actions-run";
Social cards for every page: og.g1t.sh8import { page } from "../../lib/meta";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs9import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
10import { Button, ErrorText, TimeAgo } from "../../components/ui";
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 2411import { listArtifacts } from "../../lib/artifacts.server";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs12import { actions } from "../../lib/services.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look13import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
14import { accessTo, refusal } from "../../lib/access.server";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending15import { useRefreshWhile } from "../../lib/refresh";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs16
Social cards for every page: og.g1t.sh17export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs18 const run = loaderData?.detail.run;
Social cards for every page: og.g1t.sh19 return page(args, { title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` });
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs20}
21
22export async function loader({ params, context }: Route.LoaderArgs) {
23 const viewer = getViewer(context);
24 const detail = unwrap(await actions.run({ namespace: params.owner, name: params.repo }, viewer, params.id));
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 2425 const artifacts = await listArtifacts(params.id).catch(() => []);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look26 // Cancelling and re-running need Write.
27 return { detail, artifacts, member: (await accessTo(context, params)).can.run };
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs28}
29
30export async function action({ request, params, context }: Route.ActionArgs) {
31 assertSameOrigin(request);
32 const user = requireUser(context, request);
33 const repo = { namespace: params.owner, name: params.repo };
34 const intent = String((await request.formData()).get("intent"));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look35 const refused = await refusal(context, params, "run");
36 if (refused) return { error: refused };
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs37 const done =
38 intent === "cancel"
39 ? await actions.cancel(user, repo, params.id)
40 : await actions.rerun(user, repo, params.id, intent === "rerun-failed");
41 return done.ok ? {} : { error: done.error.message };
42}
43
44const ANNOTATION_ICON: Record<Annotation["level"], ReactNode> = {
45 error: <XCircle size={14} className="mt-0.5 shrink-0 text-danger" />,
46 warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />,
47 notice: <Info size={14} className="mt-0.5 shrink-0 text-muted" />,
48};
49
50function StepRow({ step, text, defaultOpen }: { step: StepState; text: string | undefined; defaultOpen: boolean }) {
51 return (
52 <details className="group border-t border-line first:border-t-0" open={defaultOpen}>
53 <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40">
54 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
55 <StatusIcon status={step.status} conclusion={step.conclusion} size={14} />
56 <span className={`min-w-0 truncate ${step.conclusion === "skipped" ? "text-faint" : ""}`}>{step.name}</span>
57 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(step.startedAt, step.finishedAt)}</span>
58 </summary>
59 <div className="border-t border-line bg-bg/60">
60 {text === undefined ? (
61 <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output yet."}</p>
62 ) : (
63 <LogText text={text} />
64 )}
65 </div>
66 </details>
67 );
68}
69
New workspaces are told to connect a model, before anything fails70/** Why a job did not run as asked, with "Integrations" linked where it says so. */
71function Reason({ text, workspace }: { text: string; workspace: string }) {
72 const [before, after] = text.split("Integrations");
73 return (
74 <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">
75 {after === undefined ? (
76 text
77 ) : (
78 <>
79 {before}
80 <Link to={`/${workspace}/-/integrations`} className="text-accent hover:underline">
81 Integrations
82 </Link>
83 {after}
84 </>
85 )}
86 </p>
87 );
88}
89
Fast pages, required checks on the branch, self-hosted runners, honest incidents90/** Where the job ran: g1t's own runners, or a self-hosted one by name. */
91function RanOn({ job, workspace }: { job: Job; workspace: string }) {
92 if (job.selfHosted) {
93 return (
94 <Link
95 to={`/${workspace}/-/runners`}
96 className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-0.5 text-xs text-muted hover:text-fg"
97 title="Self-hosted runner"
98 >
99 <ServerCog size={13} />
100 {job.runner ? `Self-hosted: ${job.runner}` : "Self-hosted"}
101 </Link>
102 );
103 }
104 if (!job.startedAt) return null;
105 return (
106 <span className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-0.5 text-xs text-muted">
107 <Cloud size={13} />
108 g1t
109 </span>
110 );
111}
112
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs113function JobView({ job, base }: { job: Job; base: string }) {
114 const live = job.status !== "completed";
115 const log = useJobLog(`${base}/actions/jobs/${job.id}/log`, live);
116 const failed = job.steps.find((s) => s.conclusion === "failure");
117 const running = job.steps.find((s) => s.status === "in_progress");
118 return (
119 <section className="min-w-0 space-y-4">
120 <div className="flex flex-wrap items-center gap-3">
121 <StatusIcon status={job.status} conclusion={job.conclusion} size={18} />
122 <h3 className="text-base font-semibold">{job.name}</h3>
123 <span className="text-sm text-muted">
124 {standingWord(job)}
125 {job.startedAt && ` · ${duration(job.startedAt, job.finishedAt)}`}
126 </span>
Fast pages, required checks on the branch, self-hosted runners, honest incidents127 <RanOn job={job} workspace={base.split("/")[1]!} />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs128 </div>
New workspaces are told to connect a model, before anything fails129 {job.reason && <Reason text={job.reason} workspace={base.split("/")[1]} />}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs130 {job.annotations.length > 0 && (
131 <ul className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
132 {job.annotations.map((note, index) => (
133 <li key={index} className="flex gap-2">
134 {ANNOTATION_ICON[note.level]}
135 <span className="min-w-0">
136 {note.title && <span className="font-medium">{note.title}: </span>}
137 <span className="whitespace-pre-wrap">{note.message}</span>
138 {note.file && (
139 <span className="block font-mono text-xs text-faint">
140 {note.file}
141 {note.line != null && `:${note.line}`}
142 </span>
143 )}
144 </span>
145 </li>
146 ))}
147 </ul>
148 )}
149 <div className="overflow-hidden rounded-xl border border-line bg-surface">
150 <StepRow
151 step={{ number: 0, name: "Set up job", status: job.startedAt ? "completed" : "queued", conclusion: job.startedAt ? "success" : null, startedAt: job.startedAt, finishedAt: job.startedAt }}
152 text={log.get(0)}
153 defaultOpen={false}
154 />
155 {job.steps.map((step) => (
156 <StepRow
157 key={step.number}
158 step={step}
159 text={log.get(step.number)}
160 defaultOpen={step.number === (failed ?? running)?.number}
161 />
162 ))}
163 </div>
164 </section>
165 );
166}
167
168export default function ActionsRun({ loaderData, actionData, params }: Route.ComponentProps) {
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24169 const { detail, artifacts, member } = loaderData;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs170 const { run, jobs, notes } = detail;
171 const base = `/${params.owner}/${params.repo}`;
172 const [search] = useSearchParams();
173 const busy = useNavigation().state === "submitting";
174 const live = run.status !== "completed";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending175 useRefreshWhile(live, 2500);
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs176
177 // The job asked for, else one that failed, is running, or the first.
178 const selected =
179 jobs.find((job) => job.id === search.get("job")) ??
180 jobs.find((job) => job.conclusion === "failure" && job.steps.length > 0) ??
181 jobs.find((job) => job.status === "in_progress") ??
182 jobs[0];
183 const anyFailed = jobs.some((job) => job.conclusion === "failure" || job.conclusion === "cancelled");
184
185 return (
186 <div className="max-w-6xl space-y-6">
187 <header className="space-y-3">
188 <Link to={`${base}/actions?workflow=${run.workflowId}`} className="text-sm text-muted hover:text-fg">
189 {run.name}
190 </Link>
191 <div className="flex flex-wrap items-start justify-between gap-4">
192 <h2 className="flex min-w-0 items-center gap-2.5 text-xl font-semibold tracking-tight">
193 <StatusIcon status={run.status} conclusion={run.conclusion} size={20} />
194 <span className="min-w-0 truncate">{run.title || run.name}</span>
195 <span className="font-normal text-muted">#{run.number}</span>
196 </h2>
197 {member && !run.error && (
198 <Form method="post" className="flex gap-2">
199 {live ? (
200 <Button type="submit" name="intent" value="cancel" variant="quiet" disabled={busy}>
201 <Square size={13} />
202 Cancel run
203 </Button>
204 ) : (
205 <>
206 {anyFailed && (
207 <Button type="submit" name="intent" value="rerun-failed" variant="quiet" disabled={busy}>
208 <RotateCw size={13} />
209 Re-run failed jobs
210 </Button>
211 )}
212 <Button type="submit" name="intent" value="rerun" variant="quiet" disabled={busy}>
213 <RotateCw size={13} />
214 Re-run all jobs
215 </Button>
216 </>
217 )}
218 </Form>
219 )}
220 </div>
221 <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
222 <span>{standingWord(run)}</span>
223 <span className="inline-flex items-center gap-1 font-mono text-xs">
224 <GitBranch size={12} />
225 {shortRef(run.ref)}
226 </span>
227 <Link to={`${base}/commit/${run.sha}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
228 <GitCommitHorizontal size={12} />
229 {run.sha.slice(0, 7)}
230 </Link>
231 {run.pull != null && (
232 <Link to={`${base}/pull/${run.pull}`} className="hover:text-fg">
233 #{run.pull}
234 </Link>
235 )}
236 <span>
237 {run.event}
238 {run.actor && ` by ${run.actor}`} · <TimeAgo at={run.createdAt} />
239 </span>
240 {run.startedAt && <span className="font-mono text-xs">{duration(run.startedAt, run.finishedAt)}</span>}
241 {run.attempt > 1 && <span>Attempt {run.attempt}</span>}
242 </p>
243 </header>
244
245 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
246 {run.error && (
247 <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm">
248 <p className="font-medium text-danger">The workflow file could not be used</p>
249 <p className="mt-1 whitespace-pre-wrap text-fg/85">{run.error}</p>
250 </div>
251 )}
252 <Notes notes={notes} />
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24253 {artifacts.length > 0 && (
254 <section className="rounded-xl border border-line bg-surface p-4">
255 <h3 className="flex items-center gap-2 text-sm font-medium">
256 <Package size={14} className="text-muted" />
257 Artifacts
258 <span className="font-normal text-faint">· kept for 14 days</span>
259 </h3>
260 <ul className="mt-3 divide-y divide-line text-sm">
261 {artifacts.map((artifact) => (
262 <li key={artifact.name} className="flex items-center gap-3 py-2">
263 <span className="min-w-0 grow truncate font-mono text-[0.8125rem]">{artifact.name}</span>
264 <span className="shrink-0 text-xs text-faint">{Math.max(1, Math.round(artifact.size / 1024))} KB</span>
265 <a
266 href={`${base}/actions/runs/${run.id}/artifacts/${encodeURIComponent(artifact.name)}`}
267 className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-muted ring-1 ring-line hover:text-fg"
268 >
269 <Download size={12} />
270 Download
271 </a>
272 </li>
273 ))}
274 </ul>
275 </section>
276 )}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs277
278 {jobs.length > 0 && (
279 <div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
280 <nav aria-label="Jobs" className="space-y-0.5 text-sm">
281 {jobs.map((job) => (
282 <Link
283 key={job.id}
284 to={`?job=${job.id}`}
285 preventScrollReset
286 className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
287 job.id === selected?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
288 }`}
289 >
290 <StatusIcon status={job.status} conclusion={job.conclusion} size={14} />
291 <span className="min-w-0 truncate">{job.name}</span>
292 <span className="ml-auto shrink-0 font-mono text-xs text-faint">{duration(job.startedAt, job.finishedAt)}</span>
293 </Link>
294 ))}
295 </nav>
296 {selected && <JobView key={selected.id} job={selected} base={base} />}
297 </div>
298 )}
299 </div>
300 );
301}