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.

384 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 the file when you save, and that
134 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 name="source" required defaultValue={workflow?.source} placeholder={`${workspace}/policies/.g1t/workflows/scan.yml@main`} />
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
170 value="selected"
171 label="Repositories that match"
172 description="Names or patterns, one per line. * matches any characters; !name leaves one out."
173 />
174 </RadioGroup>
175 {target === "selected" && (
176 <Textarea
177 name="repositories"
178 rows={4}
179 aria-label="Repository names and patterns"
180 defaultValue={workflow?.repositories.join("\n")}
181 placeholder={"web\napi-*\n!api-sandbox"}
182 />
183 )}
184 </div>
185 <div className="space-y-2">
186 <p className="text-sm font-medium text-muted">Visibility</p>
187 <SelectField
188 name="visibility"
189 defaultValue={workflow?.visibility ?? "all"}
190 aria-label="Visibility"
191 options={[
192 { value: "all", label: "Public and private repositories" },
193 { value: "public", label: "Public repositories only" },
194 { value: "private", label: "Private repositories only" },
195 ]}
196 />
197 <p className="text-xs text-faint">A source in a private repository runs in private repositories only.</p>
198 </div>
199 <ErrorText>{result && !result.ok ? result.error : null}</ErrorText>
200 <DialogFooter>
201 <SubmitButton fetcher={fetcher} pending="Saving…">
202 {workflow ? "Save" : "Add workflow"}
203 </SubmitButton>
204 </DialogFooter>
205 </fetcher.Form>
206 </DialogContent>
207 </Dialog>
208 );
209}
210
211function ManagedWorkflowCard({ workspace, workflow }: { workspace: string; workflow: WorkspaceManagedWorkflow }) {
212 const fetcher = useFetcher<{ managed?: ManagedResult }>();
213 const result = fetcher.data?.managed;
214 return (
215 <li className="space-y-2 p-4">
216 <div className="flex flex-wrap items-start justify-between gap-3">
217 <div className="min-w-0">
218 <p className="flex flex-wrap items-center gap-2 font-medium">
219 {workflow.workflowName}
220 <Badge tone={workflow.mode === "required" ? "accent" : "neutral"}>{workflow.mode === "required" ? "Required" : "Default"}</Badge>
221 </p>
222 <p className="mt-0.5 font-mono text-xs break-all text-faint">{workflow.path}</p>
223 </div>
224 <div className="flex flex-wrap items-center gap-2">
225 <ManagedWorkflowDialog workspace={workspace} workflow={workflow} />
226 <fetcher.Form method="post">
227 <input type="hidden" name="intent" value="managed-refresh" />
228 <input type="hidden" name="name" value={workflow.name} />
229 <SubmitButton variant="outline" size="sm" fetcher={fetcher} pending="Copying…">
230 Copy again
231 </SubmitButton>
232 </fetcher.Form>
233 <AlertDialog>
234 <AlertDialogTrigger asChild>
235 <Button variant="outline" size="sm">
236 Remove
237 </Button>
238 </AlertDialogTrigger>
239 <AlertDialogContent>
240 <fetcher.Form method="post">
241 <input type="hidden" name="intent" value="managed-remove" />
242 <input type="hidden" name="name" value={workflow.name} />
243 <AlertDialogHeader>
244 <AlertDialogTitle>Remove {workflow.workflowName}?</AlertDialogTitle>
245 <AlertDialogDescription>It stops running in {workspace}'s repositories. Its runs stay.</AlertDialogDescription>
246 </AlertDialogHeader>
247 <AlertDialogFooter>
248 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
249 <SubmitButton variant="destructive" fetcher={fetcher} pending="Removing…">
250 Remove
251 </SubmitButton>
252 </AlertDialogFooter>
253 </fetcher.Form>
254 </AlertDialogContent>
255 </AlertDialog>
256 </div>
257 </div>
258 <dl className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[7rem_1fr]">
259 <dt className="text-muted">From</dt>
260 <dd className="min-w-0 font-mono text-xs break-all">
261 {workflow.source}
262 {workflow.sourceSha && <span className="text-faint"> at {workflow.sourceSha.slice(0, 7)}</span>}
263 </dd>
264 <dt className="text-muted">Runs in</dt>
265 <dd>{targetWords(workflow)}</dd>
266 <dt className="text-muted">Copied</dt>
267 <dd className="text-muted">
268 <TimeAgo at={workflow.updatedAt} /> by {workflow.updatedBy}
269 </dd>
270 </dl>
271 <ErrorText>{result && !result.ok ? result.error : null}</ErrorText>
272 </li>
273 );
274}
275
276function ManagedWorkflows({ workspace, managed }: { workspace: string; managed: WorkspaceManagedWorkflow[] | Failed }) {
277 return (
278 <section className="max-w-4xl space-y-4 border-t border-line pt-8">
279 <div className="flex flex-wrap items-start justify-between gap-4">
280 <div>
281 <h2 className="flex items-center gap-2 font-medium">
282 <BadgeCheck size={16} className="text-accent" />
283 Workflows this workspace manages
284 </h2>
285 <p className="mt-1 max-w-2xl text-sm text-muted">
286 Workflows that run in {workspace}'s repositories without a file in them, each as <code className="font-mono text-xs">managed/&lt;name&gt;.yml</code>
287 . Each repository's Actions page lists them, managed by {workspace}.
288 </p>
289 </div>
290 <ManagedWorkflowDialog workspace={workspace} />
291 </div>
292 {isFailed(managed) ? (
293 <Unavailable what={managed.what} />
294 ) : managed.length === 0 ? (
295 <EmptyState title="No managed workflows">
296 Add a workflow file from one of the workspace's repositories to run it in all of them, or in those you choose.
297 </EmptyState>
298 ) : (
299 <Card asChild divided className="overflow-hidden">
300 <ul>
301 {managed.map((workflow) => (
302 <ManagedWorkflowCard key={workflow.name} workspace={workspace} workflow={workflow} />
303 ))}
304 </ul>
305 </Card>
306 )}
307 </section>
308 );
309}
310
311export default function WorkspaceActionsSettingsPage({ loaderData, actionData, params }: Route.ComponentProps) {
312 const { settings, managed, owner } = loaderData;
313 return (
314 <div className="space-y-6">
315 <header>
316 <h1 className="text-xl font-semibold tracking-tight">Actions</h1>
317 <p className="mt-1 max-w-3xl text-sm text-muted">
318 What the token of each workflow job in this workspace's repositories may do when its workflow writes no{" "}
319 <code className="font-mono text-xs">permissions:</code>. Each repository can choose for itself under its Settings, Actions, within these limits.
320 </p>
321 </header>
322 <Form method="post" className="max-w-4xl space-y-8">
323 <fieldset disabled={!owner} className="space-y-8">
324 <Section title="Default for new repositories" about="What a repository made from now on gets, until it chooses.">
325 <RadioGroup name="defaultPermissions" defaultValue={settings.defaultPermissions} className="gap-3">
326 <RadioOption
327 value="read"
328 label="Read repository contents and packages"
329 description={
330 <>
331 <code className="font-mono">contents: read</code> and <code className="font-mono">packages: read</code>. The default.
332 </>
333 }
334 />
335 <RadioOption value="write" label="Read and write" description="Every permission a job's token can have." />
336 </RadioGroup>
337 <p className="text-sm text-muted">Repositories made before restricted tokens keep read and write until someone chooses otherwise.</p>
338 </Section>
339
340 <Section title="Most a repository may choose" about="No repository's default goes past this.">
341 <RadioGroup name="maxPermissions" defaultValue={settings.maxPermissions} className="gap-3">
342 <RadioOption value="write" label="Read and write" description="Each repository chooses. The default." />
343 <RadioOption
344 value="read"
345 label="Read only"
346 description="Every repository's jobs get read-only tokens unless their workflow writes permissions:."
347 />
348 </RadioGroup>
349 </Section>
350
351 <Section title="Pull requests" about="Whether a job's token may open pull requests and approve them. Off by default.">
352 <CheckboxOption
353 name="canApprovePullRequests"
354 defaultChecked={settings.canApprovePullRequests}
355 label="Allow repositories to let g1t Actions create and approve pull requests"
356 description="Each repository still turns it on for itself, under its Settings, Actions."
357 />
358 </Section>
359 </fieldset>
360
361 {owner ? (
362 <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">
363 <SubmitButton pending="Saving…">Save settings</SubmitButton>
364 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
365 <ErrorText>{actionData?.error}</ErrorText>
366 </div>
367 ) : (
368 <p className="text-sm text-muted">Only the workspace's owners can change these.</p>
369 )}
370 </Form>
371 {owner ? (
372 <ManagedWorkflows workspace={params.owner.toLowerCase()} managed={managed} />
373 ) : (
374 <section className="max-w-4xl border-t border-line pt-8">
375 <h2 className="font-medium">Workflows this workspace manages</h2>
376 <p className="mt-1 text-sm text-muted">
377 Only the workspace's owners can see and change the workflows it runs in its repositories. Each repository's Actions page lists the ones that run
378 there.
379 </p>
380 </section>
381 )}
382 </div>
383 );
384}