Skip to content

g1t/apps/web/app/routes/repo/settings-environments.tsx

438 lines19,816 bytesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-a3abfcce648e87dca'1import { ArrowLeft, ChevronRight, Layers, Plus, Trash2, Users } from "lucide-react";
2import { useState } from "react";
3import { Form, Link, redirect } from "react-router";
4
5import { MAX_ENVIRONMENT_REVIEWERS, MAX_WAIT_MINUTES } from "@g1t/contracts";
6import type { BranchPattern, Environment, EnvironmentReviewer } from "@g1t/contracts";
7
8import type { Route } from "./+types/settings-environments";
9import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10import { SettingsSection as Section } from "../../components/settings-section";
11import { Button, EmptyState, ErrorText, Input, SubmitButton, TimeAgo } from "../../components/ui";
12import {
13 AlertDialog,
14 AlertDialogAction,
15 AlertDialogCancel,
16 AlertDialogContent,
17 AlertDialogDescription,
18 AlertDialogFooter,
19 AlertDialogHeader,
20 AlertDialogTitle,
21 AlertDialogTrigger,
22} from "../../components/ui/alert-dialog";
23import { Badge } from "../../components/ui/badge";
24import { CheckboxOption } from "../../components/ui/checkbox";
25import { Hint } from "../../components/ui/hint";
26import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
27import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
28import { environmentFromForm, environmentName, environmentSummary } from "../../lib/environments";
29import { page } from "../../lib/meta";
30import { actions } from "../../lib/services.server";
31import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
32import { requireCapability, requireInsider } from "../../lib/access.server";
33
34export function meta({ params, ...args }: Route.MetaArgs) {
35 return page(args, { title: `Environments · ${params.owner}/${params.repo} · g1t` });
36}
37
38/** An environment with no rules saved: what the form starts from. */
39function blank(name: string): Environment {
40 return {
41 name,
42 reviewers: [],
43 preventSelfReview: false,
44 waitMinutes: 0,
45 branchPolicy: "all",
46 branchPatterns: [],
47 adminsBypass: true,
48 protected: false,
49 updatedAt: null,
50 updatedBy: null,
51 };
52}
53
54export async function loader({ params, context, request }: Route.LoaderArgs) {
55 // Admins; to anyone without a role here the page does not exist.
56 await requireInsider(context, params, "manage_integrations");
57 const environments = unwrap(await actions.environments({ namespace: params.owner, name: params.repo }, getViewer(context)));
58 // `?environment=` opens one environment's rules, a new one's included.
59 const asked = new URL(request.url).searchParams.get("environment");
60 const name = asked === null ? null : environmentName(asked);
61 return {
62 environments,
63 editing: name ? (environments.find((environment) => environment.name === name) ?? blank(name)) : null,
64 badName: asked !== null && name === null ? asked : null,
65 };
66}
67
68export async function action({ request, params, context }: Route.ActionArgs) {
69 assertSameOrigin(request);
70 const user = requireUser(context, request);
71 await requireCapability(context, params, "manage_integrations");
72 const repo = { namespace: params.owner, name: params.repo };
73 const form = await request.formData();
74 const name = environmentName(String(form.get("environment") ?? ""));
75 if (!name) return { saved: false, error: "An environment's name is lowercase letters, digits, - and _, up to 40." };
76 if (form.get("intent") === "delete") {
77 const removed = await actions.deleteEnvironment(user, repo, name);
78 if (!removed.ok) return { saved: false, error: removed.error.message };
79 throw redirect(`/${params.owner}/${params.repo}/settings/environments`);
80 }
81 const read = environmentFromForm(form, { reviewers: MAX_ENVIRONMENT_REVIEWERS, waitMinutes: MAX_WAIT_MINUTES });
82 if ("error" in read) return { saved: false, error: read.error };
83 const saved = await actions.setEnvironment(user, repo, name, read.change);
84 return saved.ok ? { saved: true, error: null } : { saved: false, error: saved.error.message };
85}
86
87export default function RepoEnvironments({ loaderData, actionData, params }: Route.ComponentProps) {
88 const base = `/${params.owner}/${params.repo}`;
89 const { environments, editing, badName } = loaderData;
90 return (
91 <div>
92 <RepoSettingsHeading base={base} />
93 {editing ? (
94 <EnvironmentEditor key={editing.name} environment={editing} base={base} saved={actionData?.saved ?? false} error={actionData?.error} />
95 ) : (
96 <EnvironmentList environments={environments} base={base} badName={badName} />
97 )}
98 </div>
99 );
100}
101
102function EnvironmentList({ environments, base, badName }: { environments: Environment[]; base: string; badName: string | null }) {
103 return (
104 <div className="max-w-4xl space-y-8">
105 <p className="max-w-3xl text-sm text-muted">
106 A job that names an environment with <code className="font-mono text-xs text-fg/85">environment:</code> waits
107 until the environment's protection rules let it through, and only then gets the environment's secrets. Rules can
108 ask for a review, hold jobs for a wait timer, and limit which branches and tags deploy.
109 </p>
110
111 <section className="space-y-3">
112 {environments.length === 0 ? (
113 <EmptyState title="No environments yet">
114 An environment appears here once a workflow, a job or a secret names it. You can also add rules to one by name
115 below.
116 </EmptyState>
117 ) : (
118 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
119 {environments.map((environment) => (
120 <li key={environment.name}>
121 <Link
122 to={`?environment=${encodeURIComponent(environment.name)}`}
123 className="group flex items-start gap-3 px-4 py-3.5 transition-colors hover:bg-raised/40"
124 >
125 <Layers size={16} className="mt-0.5 shrink-0 text-muted" />
126 <span className="min-w-0 grow">
127 <span className="flex flex-wrap items-center gap-2">
128 <span className="min-w-0 truncate font-mono text-sm font-medium">{environment.name}</span>
129 {environment.protected ? <Badge tone="accent">Protected</Badge> : <Badge>No rules</Badge>}
130 </span>
131 <span className="mt-1 block text-sm text-muted">
132 {environment.protected
133 ? environmentSummary(environment).join(" · ")
134 : "Named by a workflow or a secret. Jobs that deploy here start at once."}
135 </span>
136 </span>
137 <span className="mt-0.5 hidden shrink-0 text-xs text-muted group-hover:text-fg sm:inline">
138 {environment.protected ? "Edit rules" : "Add rules"}
139 </span>
140 <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
141 </Link>
142 </li>
143 ))}
144 </ul>
145 )}
146 </section>
147
148 <Section title="Add rules to an environment" about="Name an environment to protect, whether or not a workflow names it yet.">
149 {/* A plain GET: the rules open in the form below, and nothing is saved until you save them. */}
150 <Form method="get" className="flex flex-col gap-2 sm:flex-row sm:items-start">
151 <div className="min-w-0 grow">
152 <Input
153 name="environment"
154 required
155 maxLength={40}
156 pattern="[A-Za-z0-9_\-]{1,40}"
157 placeholder="production"
158 aria-label="Environment name"
159 defaultValue={badName ?? undefined}
160 />
161 <span className="mt-1.5 block text-xs text-faint">Lowercase letters, digits, - and _, up to 40.</span>
162 </div>
163 <Button type="submit" variant="quiet">
164 <Plus size={14} />
165 Add rules
166 </Button>
167 </Form>
168 {badName !== null && (
169 <ErrorText>{`“${badName}” cannot be an environment's name: use lowercase letters, digits, - and _, up to 40.`}</ErrorText>
170 )}
171 </Section>
172 </div>
173 );
174}
175
176/** Rows of a list that grows and shrinks, each with an id of its own for React. */
177type Row<T> = T & { id: number };
178let nextRow = 0;
179const rows = <T,>(items: T[]): Row<T>[] => items.map((item) => ({ ...item, id: nextRow++ }));
180
181function EnvironmentEditor({
182 environment,
183 base,
184 saved,
185 error,
186}: {
187 environment: Environment;
188 base: string;
189 saved: boolean;
190 error: string | null | undefined;
191}) {
192 const [reviewers, setReviewers] = useState<Row<EnvironmentReviewer>[]>(() => rows(environment.reviewers));
193 const [patterns, setPatterns] = useState<Row<BranchPattern>[]>(() =>
194 rows(environment.branchPatterns.length > 0 ? environment.branchPatterns : [{ name: "", type: "branch" }]),
195 );
196 const [policy, setPolicy] = useState(environment.branchPolicy);
197 const full = reviewers.length >= MAX_ENVIRONMENT_REVIEWERS;
198
199 return (
200 <div className="max-w-4xl space-y-8">
201 <div className="space-y-2">
202 <Link to={`${base}/settings/environments`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
203 <ArrowLeft size={14} />
204 All environments
205 </Link>
206 <h2 className="flex flex-wrap items-center gap-2 text-base font-semibold">
207 <Layers size={16} className="text-muted" />
208 <span className="min-w-0 break-all font-mono">{environment.name}</span>
209 {environment.protected ? <Badge tone="accent">Protected</Badge> : <Badge>No rules</Badge>}
210 </h2>
211 <p className="max-w-3xl text-sm text-muted">
212 Jobs with <code className="font-mono text-xs text-fg/85">environment: {environment.name}</code> wait until these
213 rules let them through, and only then get its secrets.
214 {!environment.protected && " Nothing holds them until you save rules."}
215 </p>
216 </div>
217
218 <Form method="post" className="space-y-8">
219 <input type="hidden" name="environment" value={environment.name} />
220 <Section
221 title="Required reviewers"
222 about={`Up to ${MAX_ENVIRONMENT_REVIEWERS} people or teams. A job waits until one of them approves it.`}
223 >
224 {reviewers.length > 0 ? (
225 <ul className="space-y-2">
226 {reviewers.map((reviewer, index) => (
227 <li key={reviewer.id} className="flex items-center gap-2">
228 <Select
229 name="reviewerType"
230 value={reviewer.type}
231 onValueChange={(type) =>
232 setReviewers((list) => list.map((row) => (row.id === reviewer.id ? { ...row, type: type as EnvironmentReviewer["type"] } : row)))
233 }
234 >
235 <SelectTrigger aria-label={`Reviewer ${index + 1} is a`} className="w-24 shrink-0">
236 <SelectValue />
237 </SelectTrigger>
238 <SelectContent>
239 <SelectItem value="user">User</SelectItem>
240 <SelectItem value="team">Team</SelectItem>
241 </SelectContent>
242 </Select>
243 <div className="min-w-0 grow">
244 <Input
245 name="reviewerName"
246 defaultValue={reviewer.name}
247 placeholder={reviewer.type === "team" ? "Team slug" : "Username"}
248 aria-label={`Reviewer ${index + 1}`}
249 maxLength={100}
250 />
251 </div>
252 <RemoveRow
253 label={`Remove reviewer ${index + 1}`}
254 onClick={() => setReviewers((list) => list.filter((row) => row.id !== reviewer.id))}
255 />
256 </li>
257 ))}
258 </ul>
259 ) : (
260 <p className="rounded-xl border border-dashed border-line px-4 py-3 text-sm text-muted">
261 No reviewers: jobs need no approval to deploy here.
262 </p>
263 )}
264 <div className="flex flex-wrap items-center gap-3">
265 <Button
266 type="button"
267 variant="quiet"
268 disabled={full}
269 onClick={() => setReviewers((list) => [...list, ...rows<EnvironmentReviewer>([{ type: "user", name: "" }])])}
270 >
271 <Users size={14} />
272 Add a reviewer
273 </Button>
274 {full && <span className="text-xs text-faint">That is the most an environment can have.</span>}
275 </div>
276 <CheckboxOption
277 name="preventSelfReview"
278 defaultChecked={environment.preventSelfReview}
279 label="Prevent self-review"
280 description="Whoever started a run cannot approve its deployments."
281 />
282 </Section>
283
284 <Section title="Wait timer" about="Minutes a job waits once it reaches the environment. 0 means no wait.">
285 <label className="block max-w-56">
286 <span className="mb-1.5 block text-sm font-medium text-muted">Minutes</span>
287 <Input
288 type="number"
289 name="waitMinutes"
290 min={0}
291 max={MAX_WAIT_MINUTES}
292 step={1}
293 defaultValue={environment.waitMinutes}
294 inputMode="numeric"
295 />
296 <span className="mt-1.5 block text-xs text-faint">From 0 to {MAX_WAIT_MINUTES.toLocaleString("en-US")} (30 days).</span>
297 </label>
298 </Section>
299
300 <Section title="Deployment branches and tags" about="Which branches and tags may run jobs that deploy here. A job on any other fails, saying so.">
301 <RadioGroup name="branchPolicy" value={policy} onValueChange={(value) => setPolicy(value as Environment["branchPolicy"])}>
302 <RadioOption value="all" label="All branches" description="Any branch or tag can deploy here." />
303 <RadioOption value="protected" label="Protected branches only" description="Only branches the repository's rules protect, the default branch included." />
304 <RadioOption
305 value="selected"
306 label="Selected branches and tags"
307 description="Only branches and tags whose names match a pattern, such as release/* or v*."
308 />
309 </RadioGroup>
310 {policy === "selected" && (
311 <div className="space-y-2 rounded-xl border border-line bg-surface p-3">
312 <ul className="space-y-2">
313 {patterns.map((pattern, index) => (
314 <li key={pattern.id} className="flex items-center gap-2">
315 <Select
316 name="patternType"
317 value={pattern.type}
318 onValueChange={(type) =>
319 setPatterns((list) => list.map((row) => (row.id === pattern.id ? { ...row, type: type as BranchPattern["type"] } : row)))
320 }
321 >
322 <SelectTrigger aria-label={`Pattern ${index + 1} matches`} className="w-24 shrink-0">
323 <SelectValue />
324 </SelectTrigger>
325 <SelectContent>
326 <SelectItem value="branch">Branch</SelectItem>
327 <SelectItem value="tag">Tag</SelectItem>
328 </SelectContent>
329 </Select>
330 <div className="min-w-0 grow">
331 <Input
332 name="patternName"
333 defaultValue={pattern.name}
334 placeholder={pattern.type === "tag" ? "v*" : "release/*"}
335 aria-label={`Pattern ${index + 1}`}
336 maxLength={255}
337 />
338 </div>
339 <RemoveRow
340 label={`Remove pattern ${index + 1}`}
341 onClick={() => setPatterns((list) => list.filter((row) => row.id !== pattern.id))}
342 />
343 </li>
344 ))}
345 </ul>
346 <Button
347 type="button"
348 variant="quiet"
349 onClick={() => setPatterns((list) => [...list, ...rows<BranchPattern>([{ name: "", type: "branch" }])])}
350 >
351 <Plus size={14} />
352 Add a pattern
353 </Button>
354 </div>
355 )}
356 </Section>
357
358 <Section title="Admins" about="Whether people with the Admin role may let jobs through themselves.">
359 <CheckboxOption
360 name="adminsBypass"
361 defaultChecked={environment.adminsBypass}
362 label="Allow admins to bypass these rules"
363 description="Someone with the Admin role may approve without being a reviewer, which also skips the wait timer."
364 />
365 </Section>
366
367 <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
368 <SubmitButton name="intent" value="save" pending="Saving…">
369 {environment.protected ? "Save rules" : "Add rules"}
370 </SubmitButton>
371 {saved && <span className="text-sm text-muted">Saved.</span>}
372 <ErrorText>{error}</ErrorText>
373 {environment.updatedBy && environment.updatedAt && !saved && !error && (
374 <span className="text-xs text-faint">
375 Last changed by <span className="font-mono">{environment.updatedBy}</span> <TimeAgo at={environment.updatedAt} />
376 </span>
377 )}
378 </div>
379 </Form>
380
381 {environment.protected && <RemoveRules name={environment.name} />}
382 </div>
383 );
384}
385
386function RemoveRow({ label, onClick }: { label: string; onClick: () => void }) {
387 return (
388 <Hint label={label}>
389 <button
390 type="button"
391 aria-label={label}
392 onClick={onClick}
393 className="flex size-9 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-danger"
394 >
395 <Trash2 size={14} />
396 </button>
397 </Hint>
398 );
399}
400
401function RemoveRules({ name }: { name: string }) {
402 return (
403 <Section title="Remove rules" about="Jobs that deploy here start at once again. The environment's secrets stay.">
404 <div>
405 <AlertDialog>
406 <AlertDialogTrigger asChild>
407 <Button type="button" variant="danger">
408 <Trash2 size={14} />
409 Remove rules
410 </Button>
411 </AlertDialogTrigger>
412 <AlertDialogContent>
413 <Form method="post" className="grid gap-4">
414 <input type="hidden" name="intent" value="delete" />
415 <input type="hidden" name="environment" value={name} />
416 <AlertDialogHeader>
417 <AlertDialogTitle>Remove the rules for {name}?</AlertDialogTitle>
418 <AlertDialogDescription>
419 Its reviewers, wait timer and branch limits are given up, and jobs that name it no longer wait. Its
420 secrets stay.
421 </AlertDialogDescription>
422 </AlertDialogHeader>
423 <AlertDialogFooter>
424 <AlertDialogCancel>Cancel</AlertDialogCancel>
425 <AlertDialogAction asChild>
426 <button type="submit">
427 <Trash2 size={14} />
428 Remove rules
429 </button>
430 </AlertDialogAction>
431 </AlertDialogFooter>
432 </Form>
433 </AlertDialogContent>
434 </AlertDialog>
435 </div>
436 </Section>
437 );
438}

This file's history is long; its oldest lines are credited to the oldest commit read.