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

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