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

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