Skip to content

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

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