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

379 lines16,252 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.

GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs1import { AlertTriangle, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending2import { useState } from "react";
3import { Form, Link, useNavigation, useSearchParams } from "react-router";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs4
5import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts";
6
7import type { Route } from "./+types/actions";
Social cards for every page: og.g1t.sh8import { page } from "../../lib/meta";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs9import { Notes, StatusIcon, duration, shortRef } from "../../components/actions";
Fast pages, required checks on the branch, self-hosted runners, honest incidents10import { AddCiPrompt } from "../../components/add-ci";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look11import { Button, ComputeNote, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
Workspace names and icons, and a component kit for every control12import { CheckboxOption } from "../../components/ui/checkbox";
13import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look14import { computeNoteFor } from "../../lib/compute.server";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs15import { actions } from "../../lib/services.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look16import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
17import { accessFor, refusal, repoFor } from "../../lib/access.server";
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending18import { useRefreshWhile } from "../../lib/refresh";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs19
Social cards for every page: og.g1t.sh20export function meta({ params, ...args }: Route.MetaArgs) {
21 return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` });
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs22}
23
24export async function loader({ params, context, request }: Route.LoaderArgs) {
25 const viewer = getViewer(context);
26 const repo = { namespace: params.owner, name: params.repo };
27 const selected = new URL(request.url).searchParams.get("workflow") ?? undefined;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look28 const found = await repoFor(context, params);
29 const can = found.ok ? accessFor(viewer, found.value).can : null;
30 // Starting runs needs Write; turning a workflow on or off, Maintain.
31 const member = can?.run ?? false;
Fast pages, required checks on the branch, self-hosted runners, honest incidents32 // Who may open the pull request that adds a starter workflow.
33 const canPush = can?.push ?? false;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look34 const [workflows, runs, computeNote] = await Promise.all([
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs35 actions.workflows(repo, viewer),
36 actions.runs(repo, viewer, { workflow: selected, limit: 50 }),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look37 // Jobs run on g1t's machines as the workspace's plan allows, or from
38 // the open-source pool on a public repository; members are told
39 // before a run is refused for it.
40 member && found.ok
41 ? computeNoteFor(params.owner, "workflow", !found.value.isPrivate).catch(() => null)
42 : Promise.resolve(null),
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs43 ]);
44 return {
45 workflows: unwrap(workflows),
46 runs: runs.ok ? runs.value : [],
New workspaces are told to connect a model, before anything fails47 member,
Fast pages, required checks on the branch, self-hosted runners, honest incidents48 canPush,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look49 manage: can?.manage_settings ?? false,
50 computeNote,
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs51 selected: selected ?? null,
52 };
53}
54
55export async function action({ request, params, context }: Route.ActionArgs) {
56 assertSameOrigin(request);
57 const user = requireUser(context, request);
58 const repo = { namespace: params.owner, name: params.repo };
59 const form = await request.formData();
60 const workflow = String(form.get("workflow") ?? "");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look61 const refused = await refusal(context, params, form.get("intent") === "toggle" ? "manage_settings" : "run");
62 if (refused) return { error: refused };
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs63 if (form.get("intent") === "toggle") {
64 const changed = await actions.setWorkflowEnabled(user, repo, workflow, form.get("enabled") === "true");
65 return changed.ok ? {} : { error: changed.error.message };
66 }
67 const inputs: Record<string, unknown> = {};
68 for (const [key, value] of form.entries()) {
69 if (key.startsWith("input.")) inputs[key.slice(6)] = value === "on" ? true : value;
70 }
71 // An unticked checkbox sends nothing: it is false.
72 for (const key of String(form.get("booleans") ?? "").split(",").filter(Boolean)) {
73 if (!(key in inputs)) inputs[key] = false;
74 }
75 const ref = String(form.get("ref") ?? "").trim() || undefined;
76 const started = await actions.dispatch(user, repo, workflow, ref, inputs);
77 return started.ok ? { started: started.value } : { error: started.error.message };
78}
79
80/** Re-reads the page every few seconds while something is still running. */
81function useLiveWhile(running: boolean) {
A click away from a live page arrives: pages stop refreshing themselves while a navigation is pending82 useRefreshWhile(running, 3000);
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs83}
84
85const EVENT_WORDS: Record<string, string> = {
86 push: "push",
87 pull_request: "pull request",
88 pull_request_target: "pull request (target)",
89 pull_request_review: "review",
90 issues: "issue",
91 issue_comment: "comment",
92 schedule: "schedule",
93 workflow_dispatch: "manual",
94};
95
96function RunRow({ run, base, showWorkflow }: { run: WorkflowRun; base: string; showWorkflow: boolean }) {
97 return (
98 <li className="border-t border-line first:border-t-0">
99 <Link to={`${base}/actions/runs/${run.id}`} className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-raised/40">
100 <span className="mt-0.5">
101 <StatusIcon status={run.status} conclusion={run.conclusion} />
102 </span>
103 <span className="min-w-0 grow">
104 <span className="block truncate text-sm font-medium">{run.title || run.name}</span>
105 <span className="mt-0.5 block truncate text-xs text-muted">
106 {showWorkflow && <span className="text-fg/80">{run.name}</span>}
107 {showWorkflow && " · "}#{run.number}
108 {run.attempt > 1 && ` (attempt ${run.attempt})`}
109 {" · "}
110 {EVENT_WORDS[run.event] ?? run.event}
111 {run.actor && ` by ${run.actor}`}
112 </span>
113 </span>
114 <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">
115 <GitBranch size={11} />
116 {shortRef(run.ref)}
117 </span>
118 <span className="w-28 shrink-0 text-right text-xs text-faint">
119 <TimeAgo at={run.createdAt} />
120 {run.startedAt && <span className="block font-mono">{duration(run.startedAt, run.finishedAt)}</span>}
121 </span>
122 </Link>
123 </li>
124 );
125}
126
127function InputField({ name, spec }: { name: string; spec: DispatchInput }) {
128 const label = (
129 <span className="mb-1 block text-xs font-medium text-muted">
130 {spec.description || name}
131 {spec.required && <span className="text-danger"> *</span>}
132 </span>
133 );
134 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";
135 if (spec.type === "boolean") {
136 return (
Workspace names and icons, and a component kit for every control137 <CheckboxOption
138 name={`input.${name}`}
139 defaultChecked={spec.default === true || spec.default === "true"}
140 label={spec.description || name}
141 />
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs142 );
143 }
144 if (spec.type === "choice" && spec.options) {
145 return (
Workspace names and icons, and a component kit for every control146 <div>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs147 {label}
Workspace names and icons, and a component kit for every control148 <Select name={`input.${name}`} defaultValue={String(spec.default ?? spec.options[0] ?? "") || undefined}>
149 <SelectTrigger size="sm" aria-label={spec.description || name}>
150 <SelectValue placeholder="Choose" />
151 </SelectTrigger>
152 <SelectContent>
153 {/* A Radix item cannot have the value "". */}
154 {spec.options.filter((option) => option !== "").map((option) => (
155 <SelectItem key={option} value={option}>
156 {option}
157 </SelectItem>
158 ))}
159 </SelectContent>
160 </Select>
161 </div>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs162 );
163 }
164 return (
165 <label className="block">
166 {label}
167 <input
168 name={`input.${name}`}
169 required={spec.required}
170 defaultValue={spec.default == null ? "" : String(spec.default)}
171 type={spec.type === "number" ? "number" : "text"}
172 className={control}
173 autoComplete="off"
174 />
175 </label>
176 );
177}
178
179function RunWorkflow({ workflow }: { workflow: Workflow }) {
180 const [open, setOpen] = useState(false);
181 const busy = useNavigation().state === "submitting";
182 const inputs = Object.entries(workflow.dispatch ?? {});
183 const booleans = inputs.filter(([, spec]) => spec.type === "boolean").map(([name]) => name);
184 return (
185 <div className="relative">
186 <Button type="button" variant="quiet" onClick={() => setOpen((v) => !v)}>
187 <Play size={14} />
188 Run workflow
189 </Button>
190 {open && (
191 <Form
192 method="post"
193 onSubmit={() => setOpen(false)}
194 className="absolute right-0 z-20 mt-2 w-80 space-y-3 rounded-xl border border-line bg-surface p-4 shadow-xl"
195 >
196 <input type="hidden" name="workflow" value={workflow.id} />
197 <input type="hidden" name="booleans" value={booleans.join(",")} />
198 <label className="block">
199 <span className="mb-1 block text-xs font-medium text-muted">Branch or tag</span>
200 <input
201 name="ref"
202 placeholder="The default branch"
203 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"
204 autoComplete="off"
205 />
206 </label>
207 {inputs.map(([name, spec]) => (
208 <InputField key={name} name={name} spec={spec} />
209 ))}
210 <Button type="submit" disabled={busy}>
211 <PlayCircle size={14} />
212 Run
213 </Button>
214 </Form>
215 )}
216 </div>
217 );
218}
219
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look220function WorkflowHeader({ workflow, base, member, manage }: { workflow: Workflow; base: string; member: boolean; manage: boolean }) {
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs221 const busy = useNavigation().state === "submitting";
222 return (
223 <div className="space-y-4">
224 <div className="flex flex-wrap items-start justify-between gap-3">
225 <div className="min-w-0">
226 <h3 className="text-lg font-semibold tracking-tight">{workflow.name}</h3>
227 <Link to={`${base}/blob/HEAD/${workflow.path}`} className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg">
228 <FileCode2 size={12} />
229 {workflow.path}
230 </Link>
231 {workflow.events.length > 0 && (
232 <p className="mt-1.5 text-xs text-muted">On {workflow.events.map((e) => EVENT_WORDS[e] ?? e).join(", ")}</p>
233 )}
234 </div>
235 {member && !workflow.error && (
236 <div className="flex items-center gap-2">
237 {workflow.dispatch && workflow.state === "active" && <RunWorkflow workflow={workflow} />}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look238 {manage && <Form method="post">
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs239 <input type="hidden" name="intent" value="toggle" />
240 <input type="hidden" name="workflow" value={workflow.id} />
241 <input type="hidden" name="enabled" value={workflow.state === "active" ? "false" : "true"} />
242 <Button type="submit" variant="quiet" disabled={busy}>
243 {workflow.state === "active" ? "Turn off" : "Turn on"}
244 </Button>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look245 </Form>}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs246 </div>
247 )}
248 </div>
249 {workflow.error && <p className="rounded-lg bg-danger/10 px-3 py-2 text-sm text-danger">{workflow.error}</p>}
250 {workflow.state === "disabled" && (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look251 <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>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs252 )}
253 <Notes notes={workflow.notes} />
254 </div>
255 );
256}
257
258const EXAMPLE = `name: CI
259on:
260 push:
261 branches: [main]
262 pull_request:
263jobs:
264 test:
265 runs-on: ubuntu-latest
266 steps:
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24267 - uses: actions/checkout@v7
268 - uses: actions/setup-node@v7
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs269 with:
Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24270 node-version: 24
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs271 - run: npm ci
272 - run: npm test`;
273
274export default function Actions({ loaderData, actionData, params }: Route.ComponentProps) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents275 const { workflows, runs, member, canPush, manage, computeNote, selected } = loaderData;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs276 const [search] = useSearchParams();
277 const base = `/${params.owner}/${params.repo}`;
278 const workflow = workflows.find((w) => w.id === selected || w.path.endsWith(`/${selected}`)) ?? null;
279 useLiveWhile(runs.some((run) => run.status !== "completed"));
280 const started = actionData && "started" in actionData ? actionData.started : null;
281 return (
282 <div className="max-w-6xl">
283 <header className="mb-6 flex flex-wrap items-start justify-between gap-4">
284 <div>
285 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
286 <PlayCircle size={18} className="text-accent" />
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales287 Workflows
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs288 </h2>
289 <p className="mt-1 max-w-2xl text-sm text-muted">
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales290 Workflows in <code className="text-fg">.g1t/workflows</code>, written as GitHub Actions and run as GitHub runs
291 them. A run on a pull request counts as its checks.
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs292 </p>
293 </div>
294 <a href="https://docs.g1t.sh/guides/actions/" className="text-sm text-muted underline underline-offset-4 hover:text-fg">
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales295 How workflows run on g1t
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs296 </a>
297 </header>
298
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look299 {member && computeNote && (
300 <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">
301 <AlertTriangle size={15} className="shrink-0 text-warn" />
302 <ComputeNote note={computeNote} />
New workspaces are told to connect a model, before anything fails303 </div>
304 )}
305
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs306 {workflows.length === 0 ? (
307 <div className="grid gap-6 lg:grid-cols-2">
Fast pages, required checks on the branch, self-hosted runners, honest incidents308 <div className="lg:col-span-2">
309 <AddCiPrompt owner={params.owner} repo={params.repo} canAdd={canPush} />
310 </div>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs311 <EmptyState title="No workflows yet">
312 Coming from GitHub? Rename <code>.github</code> to <code>.g1t</code> and push: your workflows run here as they
313 are. g1t never reads <code>.github</code>.
314 </EmptyState>
315 <div>
316 <p className="font-mono text-xs text-faint">.g1t/workflows/ci.yml</p>
317 <pre className="mt-1.5 rounded-lg bg-surface p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap ring-1 ring-line">
318 <code>{EXAMPLE}</code>
319 </pre>
320 </div>
321 </div>
322 ) : (
323 <div className="grid gap-8 lg:grid-cols-[14rem_1fr]">
324 <nav aria-label="Workflows" className="space-y-0.5 text-sm">
325 <Link
326 to="?"
327 className={`block rounded-md px-2.5 py-1.5 ${!selected ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`}
328 >
329 All workflows
330 </Link>
331 {workflows.map((w) => (
332 <Link
333 key={w.id}
334 to={`?workflow=${w.id}`}
335 className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 ${
336 w.id === workflow?.id ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
337 }`}
338 >
Workspace things at the bottom of the sidebar, and on mission control339 <span className="min-w-0 truncate">
340 {w.name}
341 {/* Two workflows of one name are told apart by their files. */}
342 {workflows.some((other) => other.id !== w.id && other.name === w.name) && (
343 <span className="block truncate font-mono text-[0.6875rem] text-faint">{w.path}</span>
344 )}
345 </span>
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs346 {w.error && <AlertTriangle size={12} className="shrink-0 text-danger" aria-label="Its file has a problem" />}
347 {w.state === "disabled" && <span className="ml-auto shrink-0 text-xs text-faint">off</span>}
348 </Link>
349 ))}
350 </nav>
351 <div className="min-w-0 space-y-6">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look352 {workflow && <WorkflowHeader workflow={workflow} base={base} member={member} manage={manage} />}
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs353 {started && (
354 <p className="text-sm text-muted">
355 Started{" "}
356 <Link to={`${base}/actions/runs/${started.id}`} className="text-fg underline underline-offset-4">
357 run #{started.number}
358 </Link>
359 .
360 </p>
361 )}
362 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
363 {runs.length === 0 ? (
364 <EmptyState title="No runs yet">
365 {workflow?.dispatch ? "Run it by hand, or wait for what starts it." : "Runs appear here when something starts one."}
366 </EmptyState>
367 ) : (
368 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
369 {runs.map((run) => (
370 <RunRow key={run.id} run={run} base={base} showWorkflow={!search.get("workflow")} />
371 ))}
372 </ul>
373 )}
374 </div>
375 </div>
376 )}
377 </div>
378 );
379}