flagon-io/g1t

public

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

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

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