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.

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