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.

g1t.sh
388 linesCodeBlameRaw
1import { BadgeCheck, Plus } from "lucide-react";
2import { useEffect, useState } from "react";
3import { Form, useFetcher } from "react-router";
4
5import type { WorkspaceActionsSettings, WorkspaceManagedWorkflow } from "@g1t/contracts";
6
7import type { Route } from "./+types/actions-settings";
8import { SettingsSection as Section } from "../../components/settings-section";
9import { EmptyState, ErrorText, Field, Input, SubmitButton, Textarea, TimeAgo } from "../../components/ui";
10import {
11 AlertDialog,
12 AlertDialogCancel,
13 AlertDialogContent,
14 AlertDialogDescription,
15 AlertDialogFooter,
16 AlertDialogHeader,
17 AlertDialogTitle,
18 AlertDialogTrigger,
19} from "../../components/ui/alert-dialog";
20import { Badge } from "../../components/ui/badge";
21import { Button } from "../../components/ui/button";
22import { Card } from "../../components/ui/card";
23import { CheckboxOption } from "../../components/ui/checkbox";
24import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog";
25import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
26import { SelectField } from "../../components/ui/select";
27import { page } from "../../lib/meta";
28import { actions } from "../../lib/services.server";
29import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
30
31export function meta({ params, ...args }: Route.MetaArgs) {
32 return page(args, { title: `Actions · ${params.owner} · g1t` });
33}
34
35export async function loader({ params, context }: Route.LoaderArgs) {
36 const viewer = getViewer(context);
37 // Members may see the policy; owners change it.
38 const role = roleIn(viewer, params.owner);
39 if (!role) throw new Response(null, { status: 404 });
40 const workspace = params.owner.toLowerCase();
41 const settings = unwrap(await actions.workspaceActionsSettings(workspace, viewer));
42 // The workflows it manages, and their sources, are its owners' to see.
43 const managed = role === "owner" ? unwrap(await actions.workspaceManagedWorkflows(workspace, viewer)) : [];
44 return { settings, managed, owner: role === "owner" };
45}
46
47/** One per line, or separated by commas. */
48function patterns(text: string): string[] {
49 return text
50 .split(/[\n,]/)
51 .map((line) => line.trim())
52 .filter(Boolean);
53}
54
55type ManagedResult = { ok: true; error: null } | { ok: false; error: string };
56
57async function managedAction(form: FormData, user: Parameters<typeof actions.removeWorkspaceManagedWorkflow>[0], workspace: string): Promise<ManagedResult> {
58 const intent = form.get("intent");
59 const name = String(form.get("name") ?? "").trim();
60 const done = (result: { ok: true } | { ok: false; error: { message: string } }): ManagedResult =>
61 result.ok ? { ok: true, error: null } : { ok: false, error: result.error.message };
62 if (intent === "managed-remove") return done(await actions.removeWorkspaceManagedWorkflow(user, workspace, name));
63 if (intent === "managed-refresh") return done(await actions.setWorkspaceManagedWorkflow(user, workspace, name, { refresh: true }));
64 const mode = form.get("mode") === "required" ? "required" : "default";
65 const visibility = String(form.get("visibility") ?? "all") as "all" | "public" | "private";
66 const change = {
67 source: String(form.get("source") ?? "").trim() || undefined,
68 mode,
69 repositories: form.get("target") === "all" ? [] : patterns(String(form.get("repositories") ?? "")),
70 visibility,
71 } as const;
72 return done(await actions.setWorkspaceManagedWorkflow(user, workspace, name, change, form.get("create") === "true"));
73}
74
75export async function action({ request, params, context }: Route.ActionArgs) {
76 assertSameOrigin(request);
77 if (roleIn(getViewer(context), params.owner) !== "owner") throw new Response(null, { status: 404 });
78 const user = requireUser(context, request);
79 const form = await request.formData();
80 if (String(form.get("intent") ?? "").startsWith("managed-")) {
81 return { saved: false, error: null, managed: await managedAction(form, user, params.owner.toLowerCase()) };
82 }
83 const level = (name: string): "read" | "write" => (form.get(name) === "write" ? "write" : "read");
84 const change: Partial<WorkspaceActionsSettings> = {
85 defaultPermissions: level("defaultPermissions"),
86 maxPermissions: level("maxPermissions"),
87 canApprovePullRequests: form.get("canApprovePullRequests") === "on",
88 };
89 const saved = await actions.setWorkspaceActionsSettings(user, params.owner.toLowerCase(), change);
90 return saved.ok ? { saved: true, error: null } : { saved: false, error: saved.error.message };
91}
92
93/** Where a workflow runs, in words. */
94function targetWords(workflow: WorkspaceManagedWorkflow): string {
95 const which = workflow.repositories.length === 0 ? "Every repository" : workflow.repositories.join(", ");
96 if (workflow.visibility === "public") return `${which}, public ones only`;
97 if (workflow.visibility === "private") return `${which}, private ones only`;
98 return which;
99}
100
101/** The form that adds a managed workflow, or changes one. */
102function ManagedWorkflowDialog({ workspace, workflow }: { workspace: string; workflow?: WorkspaceManagedWorkflow }) {
103 const [open, setOpen] = useState(false);
104 const fetcher = useFetcher<{ managed?: ManagedResult }>();
105 const result = fetcher.data?.managed;
106 const [target, setTarget] = useState(workflow && workflow.repositories.length > 0 ? "selected" : "all");
107 useEffect(() => {
108 if (fetcher.state === "idle" && result?.ok) setOpen(false);
109 }, [fetcher.state, result]);
110 return (
111 <Dialog open={open} onOpenChange={setOpen}>
112 <DialogTrigger asChild>
113 {workflow ? (
114 <Button variant="outline" size="sm">
115 Edit
116 </Button>
117 ) : (
118 <Button variant="outline">
119 <Plus size={14} />
120 Add workflow
121 </Button>
122 )}
123 </DialogTrigger>
124 <DialogContent>
125 <DialogHeader>
126 <DialogTitle>{workflow ? `Edit ${workflow.workflowName}` : "Add a workflow"}</DialogTitle>
127 <DialogDescription>
128 It runs in each repository it targets as that repository's own workflow, with its token and secrets. g1t copies
129 the file when you save, and that copy runs until you copy it again.
130 </DialogDescription>
131 </DialogHeader>
132 <fetcher.Form method="post" className="space-y-4">
133 <input type="hidden" name="intent" value="managed-save" />
134 <input type="hidden" name="create" value={workflow ? "false" : "true"} />
135 {workflow ? (
136 <input type="hidden" name="name" value={workflow.name} />
137 ) : (
138 <Field label="Name" hint="Lowercase letters, digits and hyphens. It runs as managed/<name>.yml.">
139 <Input name="name" required pattern="[a-z0-9-]{1,64}" placeholder="security-scan" />
140 </Field>
141 )}
142 <Field label="Source" hint={`A workflow file in one of ${workspace}'s repositories, at a branch, tag or commit.`}>
143 <Input
144 name="source"
145 required
146 defaultValue={workflow?.source}
147 placeholder={`${workspace}/policies/.g1t/workflows/scan.yml@main`}
148 />
149 </Field>
150 <div className="space-y-2">
151 <p className="text-sm font-medium text-muted">Mode</p>
152 <RadioGroup name="mode" defaultValue={workflow?.mode ?? "required"} className="gap-3">
153 <RadioOption
154 value="required"
155 label="Required"
156 description="Runs in every repository it targets. A repository's workflow of the same name does not run in its place, and no one can turn it off there, so a ruleset can require its check."
157 />
158 <RadioOption
159 value="default"
160 label="Default"
161 description="Runs where the repository has no workflow of the same name. A repository's admins can turn it off."
162 />
163 </RadioGroup>
164 </div>
165 <div className="space-y-2">
166 <p className="text-sm font-medium text-muted">Repositories</p>
167 <RadioGroup name="target" value={target} onValueChange={setTarget} className="gap-3">
168 <RadioOption value="all" label="All repositories" />
169 <RadioOption value="selected" label="Repositories that match" description="Names or patterns, one per line. * matches any characters; !name leaves one out." />
170 </RadioGroup>
171 {target === "selected" && (
172 <Textarea
173 name="repositories"
174 rows={4}
175 aria-label="Repository names and patterns"
176 defaultValue={workflow?.repositories.join("\n")}
177 placeholder={"web\napi-*\n!api-sandbox"}
178 />
179 )}
180 </div>
181 <div className="space-y-2">
182 <p className="text-sm font-medium text-muted">Visibility</p>
183 <SelectField
184 name="visibility"
185 defaultValue={workflow?.visibility ?? "all"}
186 aria-label="Visibility"
187 options={[
188 { value: "all", label: "Public and private repositories" },
189 { value: "public", label: "Public repositories only" },
190 { value: "private", label: "Private repositories only" },
191 ]}
192 />
193 <p className="text-xs text-faint">A source in a private repository runs in private repositories only.</p>
194 </div>
195 <ErrorText>{result && !result.ok ? result.error : null}</ErrorText>
196 <DialogFooter>
197 <SubmitButton fetcher={fetcher} pending="Saving…">
198 {workflow ? "Save" : "Add workflow"}
199 </SubmitButton>
200 </DialogFooter>
201 </fetcher.Form>
202 </DialogContent>
203 </Dialog>
204 );
205}
206
207function ManagedWorkflowCard({ workspace, workflow }: { workspace: string; workflow: WorkspaceManagedWorkflow }) {
208 const fetcher = useFetcher<{ managed?: ManagedResult }>();
209 const result = fetcher.data?.managed;
210 return (
211 <li className="space-y-2 p-4">
212 <div className="flex flex-wrap items-start justify-between gap-3">
213 <div className="min-w-0">
214 <p className="flex flex-wrap items-center gap-2 font-medium">
215 {workflow.workflowName}
216 <Badge tone={workflow.mode === "required" ? "accent" : "neutral"}>{workflow.mode === "required" ? "Required" : "Default"}</Badge>
217 </p>
218 <p className="mt-0.5 font-mono text-xs break-all text-faint">{workflow.path}</p>
219 </div>
220 <div className="flex flex-wrap items-center gap-2">
221 <ManagedWorkflowDialog workspace={workspace} workflow={workflow} />
222 <fetcher.Form method="post">
223 <input type="hidden" name="intent" value="managed-refresh" />
224 <input type="hidden" name="name" value={workflow.name} />
225 <SubmitButton variant="outline" size="sm" fetcher={fetcher} pending="Copying…">
226 Copy again
227 </SubmitButton>
228 </fetcher.Form>
229 <AlertDialog>
230 <AlertDialogTrigger asChild>
231 <Button variant="outline" size="sm">
232 Remove
233 </Button>
234 </AlertDialogTrigger>
235 <AlertDialogContent>
236 <fetcher.Form method="post">
237 <input type="hidden" name="intent" value="managed-remove" />
238 <input type="hidden" name="name" value={workflow.name} />
239 <AlertDialogHeader>
240 <AlertDialogTitle>Remove {workflow.workflowName}?</AlertDialogTitle>
241 <AlertDialogDescription>
242 It stops running in {workspace}'s repositories. Its runs stay.
243 </AlertDialogDescription>
244 </AlertDialogHeader>
245 <AlertDialogFooter>
246 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
247 <SubmitButton variant="destructive" fetcher={fetcher} pending="Removing…">
248 Remove
249 </SubmitButton>
250 </AlertDialogFooter>
251 </fetcher.Form>
252 </AlertDialogContent>
253 </AlertDialog>
254 </div>
255 </div>
256 <dl className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[7rem_1fr]">
257 <dt className="text-muted">From</dt>
258 <dd className="min-w-0 font-mono text-xs break-all">
259 {workflow.source}
260 {workflow.sourceSha && <span className="text-faint"> at {workflow.sourceSha.slice(0, 7)}</span>}
261 </dd>
262 <dt className="text-muted">Runs in</dt>
263 <dd>{targetWords(workflow)}</dd>
264 <dt className="text-muted">Copied</dt>
265 <dd className="text-muted">
266 <TimeAgo at={workflow.updatedAt} /> by {workflow.updatedBy}
267 </dd>
268 </dl>
269 <ErrorText>{result && !result.ok ? result.error : null}</ErrorText>
270 </li>
271 );
272}
273
274function ManagedWorkflows({ workspace, managed }: { workspace: string; managed: WorkspaceManagedWorkflow[] }) {
275 return (
276 <section className="max-w-4xl space-y-4 border-t border-line pt-8">
277 <div className="flex flex-wrap items-start justify-between gap-4">
278 <div>
279 <h2 className="flex items-center gap-2 font-medium">
280 <BadgeCheck size={16} className="text-accent" />
281 Workflows this workspace manages
282 </h2>
283 <p className="mt-1 max-w-2xl text-sm text-muted">
284 Workflows that run in {workspace}'s repositories without a file in them, each as{" "}
285 <code className="font-mono text-xs">managed/&lt;name&gt;.yml</code>. Each repository's Actions page lists them, managed by{" "}
286 {workspace}.
287 </p>
288 </div>
289 <ManagedWorkflowDialog workspace={workspace} />
290 </div>
291 {managed.length === 0 ? (
292 <EmptyState title="No managed workflows">
293 Add a workflow file from one of the workspace's repositories to run it in all of them, or in those you choose.
294 </EmptyState>
295 ) : (
296 <Card asChild divided className="overflow-hidden">
297 <ul>
298 {managed.map((workflow) => (
299 <ManagedWorkflowCard key={workflow.name} workspace={workspace} workflow={workflow} />
300 ))}
301 </ul>
302 </Card>
303 )}
304 </section>
305 );
306}
307
308export default function WorkspaceActionsSettingsPage({ loaderData, actionData, params }: Route.ComponentProps) {
309 const { settings, managed, owner } = loaderData;
310 return (
311 <div className="space-y-6">
312 <header>
313 <h1 className="text-xl font-semibold tracking-tight">Actions</h1>
314 <p className="mt-1 max-w-3xl text-sm text-muted">
315 What the token of each workflow job in this workspace's repositories may do when its workflow writes no{" "}
316 <code className="font-mono text-xs">permissions:</code>. Each repository can choose for itself under its Settings,
317 Actions, within these limits.
318 </p>
319 </header>
320 <Form method="post" className="max-w-4xl space-y-8">
321 <fieldset disabled={!owner} className="space-y-8">
322 <Section title="Default for new repositories" about="What a repository made from now on gets, until it chooses.">
323 <RadioGroup name="defaultPermissions" defaultValue={settings.defaultPermissions} className="gap-3">
324 <RadioOption
325 value="read"
326 label="Read repository contents and packages"
327 description={
328 <>
329 <code className="font-mono">contents: read</code> and <code className="font-mono">packages: read</code>.
330 The default.
331 </>
332 }
333 />
334 <RadioOption value="write" label="Read and write" description="Every permission a job's token can have." />
335 </RadioGroup>
336 <p className="text-sm text-muted">
337 Repositories made before restricted tokens keep read and write until someone chooses otherwise.
338 </p>
339 </Section>
340
341 <Section title="Most a repository may choose" about="No repository's default goes past this.">
342 <RadioGroup name="maxPermissions" defaultValue={settings.maxPermissions} className="gap-3">
343 <RadioOption value="write" label="Read and write" description="Each repository chooses. The default." />
344 <RadioOption
345 value="read"
346 label="Read only"
347 description="Every repository's jobs get read-only tokens unless their workflow writes permissions:."
348 />
349 </RadioGroup>
350 </Section>
351
352 <Section
353 title="Pull requests"
354 about="Whether a job's token may open pull requests and approve them. Off by default."
355 >
356 <CheckboxOption
357 name="canApprovePullRequests"
358 defaultChecked={settings.canApprovePullRequests}
359 label="Allow repositories to let g1t Actions create and approve pull requests"
360 description="Each repository still turns it on for itself, under its Settings, Actions."
361 />
362 </Section>
363 </fieldset>
364
365 {owner ? (
366 <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
367 <SubmitButton pending="Saving…">Save settings</SubmitButton>
368 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
369 <ErrorText>{actionData?.error}</ErrorText>
370 </div>
371 ) : (
372 <p className="text-sm text-muted">Only the workspace's owners can change these.</p>
373 )}
374 </Form>
375 {owner ? (
376 <ManagedWorkflows workspace={params.owner.toLowerCase()} managed={managed} />
377 ) : (
378 <section className="max-w-4xl border-t border-line pt-8">
379 <h2 className="font-medium">Workflows this workspace manages</h2>
380 <p className="mt-1 text-sm text-muted">
381 Only the workspace's owners can see and change the workflows it runs in its repositories. Each repository's Actions
382 page lists the ones that run there.
383 </p>
384 </section>
385 )}
386 </div>
387 );
388}