flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

278 lines12,516 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.

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