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.

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