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