pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/routes/repo/actions.tsx

378 lines16,183 bytesCodeBlame
1import { AlertTriangle, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
2import { useEffect, useState } from "react";
3import { Form, Link, useNavigation, useRevalidator, useSearchParams } from "react-router";
4
5import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts";
6
7import type { Route } from "./+types/actions";
8import { page } from "../../lib/meta";
9import { Notes, StatusIcon, duration, shortRef } from "../../components/actions";
10import { Button, ComputeNote, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
11import { CheckboxOption } from "../../components/ui/checkbox";
12import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
13import { computeNoteFor } from "../../lib/compute.server";
14import { actions } from "../../lib/services.server";
15import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
16import { accessFor, refusal, repoFor } from "../../lib/access.server";
17
18export function meta({ params, ...args }: Route.MetaArgs) {
19 return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` });
20}
21
22export async function loader({ params, context, request }: Route.LoaderArgs) {
23 const viewer = getViewer(context);
24 const repo = { namespace: params.owner, name: params.repo };
25 const selected = new URL(request.url).searchParams.get("workflow") ?? undefined;
26 const found = await repoFor(context, params);
27 const can = found.ok ? accessFor(viewer, found.value).can : null;
28 // Starting runs needs Write; turning a workflow on or off, Maintain.
29 const member = can?.run ?? false;
30 const [workflows, runs, computeNote] = await Promise.all([
31 actions.workflows(repo, viewer),
32 actions.runs(repo, viewer, { workflow: selected, limit: 50 }),
33 // Jobs run on g1t's machines as the workspace's plan allows, or from
34 // the open-source pool on a public repository; members are told
35 // before a run is refused for it.
36 member && found.ok
37 ? computeNoteFor(params.owner, "workflow", !found.value.isPrivate).catch(() => null)
38 : Promise.resolve(null),
39 ]);
40 return {
41 workflows: unwrap(workflows),
42 runs: runs.ok ? runs.value : [],
43 member,
44 manage: can?.manage_settings ?? false,
45 computeNote,
46 selected: selected ?? null,
47 };
48}
49
50export async function action({ request, params, context }: Route.ActionArgs) {
51 assertSameOrigin(request);
52 const user = requireUser(context, request);
53 const repo = { namespace: params.owner, name: params.repo };
54 const form = await request.formData();
55 const workflow = String(form.get("workflow") ?? "");
56 const refused = await refusal(context, params, form.get("intent") === "toggle" ? "manage_settings" : "run");
57 if (refused) return { error: refused };
58 if (form.get("intent") === "toggle") {
59 const changed = await actions.setWorkflowEnabled(user, repo, workflow, form.get("enabled") === "true");
60 return changed.ok ? {} : { error: changed.error.message };
61 }
62 const inputs: Record<string, unknown> = {};
63 for (const [key, value] of form.entries()) {
64 if (key.startsWith("input.")) inputs[key.slice(6)] = value === "on" ? true : value;
65 }
66 // An unticked checkbox sends nothing: it is false.
67 for (const key of String(form.get("booleans") ?? "").split(",").filter(Boolean)) {
68 if (!(key in inputs)) inputs[key] = false;
69 }
70 const ref = String(form.get("ref") ?? "").trim() || undefined;
71 const started = await actions.dispatch(user, repo, workflow, ref, inputs);
72 return started.ok ? { started: started.value } : { error: started.error.message };
73}
74
75/** Re-reads the page every few seconds while something is still running. */
76function useLiveWhile(running: boolean) {
77 const revalidator = useRevalidator();
78 useEffect(() => {
79 if (!running) return;
80 const timer = setInterval(() => {
81 if (revalidator.state === "idle" && document.visibilityState === "visible") revalidator.revalidate();
82 }, 3000);
83 return () => clearInterval(timer);
84 }, [running, revalidator]);
85}
86
87const EVENT_WORDS: Record<string, string> = {
88 push: "push",
89 pull_request: "pull request",
90 pull_request_target: "pull request (target)",
91 pull_request_review: "review",
92 issues: "issue",
93 issue_comment: "comment",
94 schedule: "schedule",
95 workflow_dispatch: "manual",
96};
97
98function RunRow({ run, base, showWorkflow }: { run: WorkflowRun; base: string; showWorkflow: boolean }) {
99 return (
100 <li className="border-t border-line first:border-t-0">
101 <Link to={`${base}/actions/runs/${run.id}`} className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
102 <span className="mt-0.5">
103 <StatusIcon status={run.status} conclusion={run.conclusion} />
104 </span>
105 <span className="min-w-0 grow">
106 <span className="block truncate text-sm font-medium">{run.title || run.name}</span>
107 <span className="mt-0.5 block truncate text-xs text-muted">
108 {showWorkflow && <span className="text-fg/80">{run.name}</span>}
109 {showWorkflow && " · "}#{run.number}
110 {run.attempt > 1 && ` (attempt ${run.attempt})`}
111 {" · "}
112 {EVENT_WORDS[run.event] ?? run.event}
113 {run.actor && ` by ${run.actor}`}
114 </span>
115 </span>
116 <span className="hidden shrink-0 items-center gap-1 rounded-md bg-raised px-1.5 py-0.5 font-mono text-xs text-muted sm:inline-flex">
117 <GitBranch size={11} />
118 {shortRef(run.ref)}
119 </span>
120 <span className="w-28 shrink-0 text-right text-xs text-faint">
121 <TimeAgo at={run.createdAt} />
122 {run.startedAt && <span className="block font-mono">{duration(run.startedAt, run.finishedAt)}</span>}
123 </span>
124 </Link>
125 </li>
126 );
127}
128
129function InputField({ name, spec }: { name: string; spec: DispatchInput }) {
130 const label = (
131 <span className="mb-1 block text-xs font-medium text-muted">
132 {spec.description || name}
133 {spec.required && <span className="text-danger"> *</span>}
134 </span>
135 );
136 const control = "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none focus:border-accent-dim";
137 if (spec.type === "boolean") {
138 return (
139 <CheckboxOption
140 name={`input.${name}`}
141 defaultChecked={spec.default === true || spec.default === "true"}
142 label={spec.description || name}
143 />
144 );
145 }
146 if (spec.type === "choice" && spec.options) {
147 return (
148 <div>
149 {label}
150 <Select name={`input.${name}`} defaultValue={String(spec.default ?? spec.options[0] ?? "") || undefined}>
151 <SelectTrigger size="sm" aria-label={spec.description || name}>
152 <SelectValue placeholder="Choose" />
153 </SelectTrigger>
154 <SelectContent>
155 {/* A Radix item cannot have the value "". */}
156 {spec.options.filter((option) => option !== "").map((option) => (
157 <SelectItem key={option} value={option}>
158 {option}
159 </SelectItem>
160 ))}
161 </SelectContent>
162 </Select>
163 </div>
164 );
165 }
166 return (
167 <label className="block">
168 {label}
169 <input
170 name={`input.${name}`}
171 required={spec.required}
172 defaultValue={spec.default == null ? "" : String(spec.default)}
173 type={spec.type === "number" ? "number" : "text"}
174 className={control}
175 autoComplete="off"
176 />
177 </label>
178 );
179}
180
181function RunWorkflow({ workflow }: { workflow: Workflow }) {
182 const [open, setOpen] = useState(false);
183 const busy = useNavigation().state === "submitting";
184 const inputs = Object.entries(workflow.dispatch ?? {});
185 const booleans = inputs.filter(([, spec]) => spec.type === "boolean").map(([name]) => name);
186 return (
187 <div className="relative">
188 <Button type="button" variant="quiet" onClick={() => setOpen((v) => !v)}>
189 <Play size={14} />
190 Run workflow
191 </Button>
192 {open && (
193 <Form
194 method="post"
195 onSubmit={() => setOpen(false)}
196 className="absolute right-0 z-20 mt-2 w-80 space-y-3 rounded-xl border border-line bg-surface p-4 shadow-xl"
197 >
198 <input type="hidden" name="workflow" value={workflow.id} />
199 <input type="hidden" name="booleans" value={booleans.join(",")} />
200 <label className="block">
201 <span className="mb-1 block text-xs font-medium text-muted">Branch or tag</span>
202 <input
203 name="ref"
204 placeholder="The default branch"
205 className="w-full rounded-md border border-line bg-bg px-2.5 py-1.5 font-mono text-sm outline-none focus:border-accent-dim"
206 autoComplete="off"
207 />
208 </label>
209 {inputs.map(([name, spec]) => (
210 <InputField key={name} name={name} spec={spec} />
211 ))}
212 <Button type="submit" disabled={busy}>
213 <PlayCircle size={14} />
214 Run
215 </Button>
216 </Form>
217 )}
218 </div>
219 );
220}
221
222function WorkflowHeader({ workflow, base, member, manage }: { workflow: Workflow; base: string; member: boolean; manage: boolean }) {
223 const busy = useNavigation().state === "submitting";
224 return (
225 <div className="space-y-4">
226 <div className="flex flex-wrap items-start justify-between gap-3">
227 <div className="min-w-0">
228 <h3 className="text-lg font-semibold tracking-tight">{workflow.name}</h3>
229 <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg">
230 <FileCode2 size={12} />
231 {workflow.path}
232 </Link>
233 {workflow.events.length > 0 && (
234 <p className="mt-1.5 text-xs text-muted">On {workflow.events.map((e) => EVENT_WORDS[e] ?? e).join(", ")}</p>
235 )}
236 </div>
237 {member && !workflow.error && (
238 <div className="flex items-center gap-2">
239 {workflow.dispatch && workflow.state === "active" && <RunWorkflow workflow={workflow} />}
240 {manage && <Form method="post">
241 <input type="hidden" name="intent" value="toggle" />
242 <input type="hidden" name="workflow" value={workflow.id} />
243 <input type="hidden" name="enabled" value={workflow.state === "active" ? "false" : "true"} />
244 <Button type="submit" variant="quiet" disabled={busy}>
245 {workflow.state === "active" ? "Turn off" : "Turn on"}
246 </Button>
247 </Form>}
248 </div>
249 )}
250 </div>
251 {workflow.error && <p className="rounded-lg bg-danger/10 px-3 py-2 text-sm text-danger">{workflow.error}</p>}
252 {workflow.state === "disabled" && (
253 <p className="rounded-lg bg-raised px-3 py-2 text-sm text-muted">Turned off: nothing starts it until someone with the Maintain role turns it on.</p>
254 )}
255 <Notes notes={workflow.notes} />
256 </div>
257 );
258}
259
260const EXAMPLE = `name: CI
261on:
262 push:
263 branches: [main]
264 pull_request:
265jobs:
266 test:
267 runs-on: ubuntu-latest
268 steps:
269 - uses: actions/checkout@v7
270 - uses: actions/setup-node@v7
271 with:
272 node-version: 24
273 - run: npm ci
274 - run: npm test`;
275
276export default function Actions({ loaderData, actionData, params }: Route.ComponentProps) {
277 const { workflows, runs, member, manage, computeNote, selected } = loaderData;
278 const [search] = useSearchParams();
279 const base = `/${params.owner}/${params.repo}`;
280 const workflow = workflows.find((w) => w.id === selected || w.path.endsWith(`/${selected}`)) ?? null;
281 useLiveWhile(runs.some((run) => run.status !== "completed"));
282 const started = actionData && "started" in actionData ? actionData.started : null;
283 return (
284 <div className="max-w-6xl">
285 <header className="mb-6 flex flex-wrap items-start justify-between gap-4">
286 <div>
287 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
288 <PlayCircle size={18} className="text-accent" />
289 Workflows
290 </h2>
291 <p className="mt-1 max-w-2xl text-sm text-muted">
292 Workflows in <code className="text-fg">.g1t/workflows</code>, written as GitHub Actions and run as GitHub runs
293 them. A run on a pull request counts as its checks.
294 </p>
295 </div>
296 <a href="https://docs.g1t.sh/guides/actions/" className="text-sm text-muted underline underline-offset-4 hover:text-fg">
297 How workflows run on g1t
298 </a>
299 </header>
300
301 {member && computeNote && (
302 <div className="mb-6 flex flex-wrap items-center gap-2 rounded-xl bg-surface px-4 py-3 text-sm ring-1 ring-line">
303 <AlertTriangle size={15} className="shrink-0 text-warn" />
304 <ComputeNote note={computeNote} />
305 </div>
306 )}
307
308 {workflows.length === 0 ? (
309 <div className="grid gap-6 lg:grid-cols-2">
310 <EmptyState title="No workflows yet">
311 Coming from GitHub? Rename <code>.github</code> to <code>.g1t</code> and push: your workflows run here as they
312 are. g1t never reads <code>.github</code>.
313 </EmptyState>
314 <div>
315 <p className="font-mono text-xs text-faint">.g1t/workflows/ci.yml</p>
316 <pre className="mt-1.5 rounded-lg bg-surface p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap ring-1 ring-line">
317 <code>{EXAMPLE}</code>
318 </pre>
319 </div>
320 </div>
321 ) : (
322 <div className="grid gap-8 lg:grid-cols-[14rem_1fr]">
323 <nav aria-label="Workflows" className="space-y-0.5 text-sm">
324 <Link
325 to="?"
326 className={`block rounded-md px-2.5 py-1.5 ${!selected ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}
327 >
328 All workflows
329 </Link>
330 {workflows.map((w) => (
331 <Link
332 key={w.id}
333 to={`?workflow=${w.id}`}
334 className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
335 w.id === workflow?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
336 }`}
337 >
338 <span className="min-w-0 truncate">
339 {w.name}
340 {/* Two workflows of one name are told apart by their files. */}
341 {workflows.some((other) => other.id !== w.id && other.name === w.name) && (
342 <span className="block truncate font-mono text-[0.6875rem] text-faint">{w.path}</span>
343 )}
344 </span>
345 {w.error && <AlertTriangle size={12} className="shrink-0 text-danger" aria-label="Its file has a problem" />}
346 {w.state === "disabled" && <span className="ml-auto shrink-0 text-xs text-faint">off</span>}
347 </Link>
348 ))}
349 </nav>
350 <div className="min-w-0 space-y-6">
351 {workflow && <WorkflowHeader workflow={workflow} base={base} member={member} manage={manage} />}
352 {started && (
353 <p className="text-sm text-muted">
354 Started{" "}
355 <Link to={`${base}/actions/runs/${started.id}`} className="text-fg underline underline-offset-4">
356 run #{started.number}
357 </Link>
358 .
359 </p>
360 )}
361 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
362 {runs.length === 0 ? (
363 <EmptyState title="No runs yet">
364 {workflow?.dispatch ? "Run it by hand, or wait for what starts it." : "Runs appear here when something starts one."}
365 </EmptyState>
366 ) : (
367 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
368 {runs.map((run) => (
369 <RunRow key={run.id} run={run} base={base} showWorkflow={!search.get("workflow")} />
370 ))}
371 </ul>
372 )}
373 </div>
374 </div>
375 )}
376 </div>
377 );
378}