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
446 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} base={base} badName={badName} />
99 )}
100 </div>
101 );
102}
103
104function EnvironmentList({ environments, base, badName }: { environments: Environment[]; base: string; 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
109 until the environment's protection rules let it through, and only then gets the environment's secrets. Rules can
110 ask for a review, hold jobs for a wait timer, and limit which branches and tags 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
117 below.
118 </EmptyState>
119 ) : (
120 <Card asChild divided className="overflow-hidden">
121 <ul>
122 {environments.map((environment) => (
123 <li key={environment.name}>
124 <Link
125 to={`?environment=${encodeURIComponent(environment.name)}`}
126 className="group flex items-start gap-3 px-4 py-3.5 transition-colors hover:bg-raised/40"
127 >
128 <Layers size={16} className="mt-0.5 shrink-0 text-muted" />
129 <span className="min-w-0 grow">
130 <span className="flex flex-wrap items-center gap-2">
131 <span className="min-w-0 truncate font-mono text-sm font-medium">{environment.name}</span>
132 {environment.protected ? <Badge tone="accent">Protected</Badge> : <Badge>No rules</Badge>}
133 </span>
134 <span className="mt-1 block text-sm text-muted">
135 {environment.protected
136 ? environmentSummary(environment).join(" · ")
137 : "Named by a workflow or a secret. Jobs that deploy here start at once."}
138 </span>
139 </span>
140 <span className="mt-0.5 hidden shrink-0 text-xs text-muted group-hover:text-fg sm:inline">
141 {environment.protected ? "Edit rules" : "Add rules"}
142 </span>
143 <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
144 </Link>
145 </li>
146 ))}
147 </ul>
148 </Card>
149 )}
150 </section>
151
152 <Section title="Add rules to an environment" about="Name an environment to protect, whether or not a workflow names it yet.">
153 {/* A plain GET: the rules open in the form below, and nothing is saved until you save them. */}
154 <Form method="get" className="flex flex-col gap-2 sm:flex-row sm:items-start">
155 <div className="min-w-0 grow">
156 <Input
157 name="environment"
158 required
159 maxLength={40}
160 pattern="[A-Za-z0-9_\-]{1,40}"
161 placeholder="production"
162 aria-label="Environment name"
163 defaultValue={badName ?? undefined}
164 />
165 <span className="mt-1.5 block text-xs text-faint">Lowercase letters, digits, - and _, up to 40.</span>
166 </div>
167 <Button type="submit" variant="outline">
168 <Plus size={14} />
169 Add rules
170 </Button>
171 </Form>
172 {badName !== null && (
173 <ErrorText>{`“${badName}” cannot be an environment's name: use lowercase letters, digits, - and _, up to 40.`}</ErrorText>
174 )}
175 </Section>
176 </div>
177 );
178}
179
180/** Rows of a list that grows and shrinks, each with an id of its own for React. */
181type Row<T> = T & { id: number };
182let nextRow = 0;
183const rows = <T,>(items: T[]): Row<T>[] => items.map((item) => ({ ...item, id: nextRow++ }));
184
185function EnvironmentEditor({
186 environment,
187 base,
188 saved,
189 error,
190}: {
191 environment: Environment;
192 base: string;
193 saved: boolean;
194 error: string | null | undefined;
195}) {
196 const [reviewers, setReviewers] = useState<Row<EnvironmentReviewer>[]>(() => rows(environment.reviewers));
197 const [patterns, setPatterns] = useState<Row<BranchPattern>[]>(() =>
198 rows(environment.branchPatterns.length > 0 ? environment.branchPatterns : [{ name: "", type: "branch" }]),
199 );
200 const [policy, setPolicy] = useState(environment.branchPolicy);
201 const full = reviewers.length >= MAX_ENVIRONMENT_REVIEWERS;
202
203 return (
204 <div className="max-w-4xl space-y-8">
205 <div className="space-y-2">
206 <Link to={`${base}/settings/environments`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
207 <ArrowLeft size={14} />
208 All environments
209 </Link>
210 <h2 className="flex flex-wrap items-center gap-2 text-base font-semibold">
211 <Layers size={16} className="text-muted" />
212 <span className="min-w-0 break-all font-mono">{environment.name}</span>
213 {environment.protected ? <Badge tone="accent">Protected</Badge> : <Badge>No rules</Badge>}
214 </h2>
215 <p className="max-w-3xl text-sm text-muted">
216 Jobs with <code className="font-mono text-xs text-fg/85">environment: {environment.name}</code> wait until these
217 rules let them through, and only then get its secrets.
218 {!environment.protected && " Nothing holds them until you save rules."}
219 </p>
220 </div>
221
222 <Form method="post" className="space-y-8">
223 <input type="hidden" name="environment" value={environment.name} />
224 <Section
225 title="Required reviewers"
226 about={`Up to ${MAX_ENVIRONMENT_REVIEWERS} people or teams. A job waits until one of them approves it.`}
227 >
228 {reviewers.length > 0 ? (
229 <ul className="space-y-2">
230 {reviewers.map((reviewer, index) => (
231 <li key={reviewer.id} className="flex items-center gap-2">
232 <Select
233 name="reviewerType"
234 value={reviewer.type}
235 onValueChange={(type) =>
236 setReviewers((list) => list.map((row) => (row.id === reviewer.id ? { ...row, type: type as EnvironmentReviewer["type"] } : row)))
237 }
238 >
239 <SelectTrigger aria-label={`Reviewer ${index + 1} is a`} className="w-24 shrink-0">
240 <SelectValue />
241 </SelectTrigger>
242 <SelectContent>
243 <SelectItem value="user">User</SelectItem>
244 <SelectItem value="team">Team</SelectItem>
245 </SelectContent>
246 </Select>
247 <div className="min-w-0 grow">
248 <Input
249 name="reviewerName"
250 defaultValue={reviewer.name}
251 placeholder={reviewer.type === "team" ? "Team slug" : "Username"}
252 aria-label={`Reviewer ${index + 1}`}
253 maxLength={100}
254 />
255 </div>
256 <RemoveRow
257 label={`Remove reviewer ${index + 1}`}
258 onClick={() => setReviewers((list) => list.filter((row) => row.id !== reviewer.id))}
259 />
260 </li>
261 ))}
262 </ul>
263 ) : (
264 <Card asChild tone="plain" className="border-dashed px-4 py-3 text-sm text-muted">
265 <p>
266 No reviewers: jobs need no approval to deploy here.
267 </p>
268 </Card>
269 )}
270 <div className="flex flex-wrap items-center gap-3">
271 <Button
272 type="button"
273 variant="outline"
274 disabled={full}
275 onClick={() => setReviewers((list) => [...list, ...rows<EnvironmentReviewer>([{ type: "user", name: "" }])])}
276 >
277 <Users size={14} />
278 Add a reviewer
279 </Button>
280 {full && <span className="text-xs text-faint">That is the most an environment can have.</span>}
281 </div>
282 <CheckboxOption
283 name="preventSelfReview"
284 defaultChecked={environment.preventSelfReview}
285 label="Prevent self-review"
286 description="Whoever started a run cannot approve its deployments."
287 />
288 </Section>
289
290 <Section title="Wait timer" about="Minutes a job waits once it reaches the environment. 0 means no wait.">
291 <label className="block max-w-56">
292 <span className="mb-1.5 block text-sm font-medium text-muted">Minutes</span>
293 <Input
294 type="number"
295 name="waitMinutes"
296 min={0}
297 max={MAX_WAIT_MINUTES}
298 step={1}
299 defaultValue={environment.waitMinutes}
300 inputMode="numeric"
301 />
302 <span className="mt-1.5 block text-xs text-faint">From 0 to {MAX_WAIT_MINUTES.toLocaleString("en-US")} (30 days).</span>
303 </label>
304 </Section>
305
306 <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.">
307 <RadioGroup name="branchPolicy" value={policy} onValueChange={(value) => setPolicy(value as Environment["branchPolicy"])}>
308 <RadioOption value="all" label="All branches" description="Any branch or tag can deploy here." />
309 <RadioOption value="protected" label="Protected branches only" description="Only branches the repository's rules protect, the default branch included." />
310 <RadioOption
311 value="selected"
312 label="Selected branches and tags"
313 description="Only branches and tags whose names match a pattern, such as release/* or v*."
314 />
315 </RadioGroup>
316 {policy === "selected" && (
317 <Card className="space-y-2 p-3">
318 <ul className="space-y-2">
319 {patterns.map((pattern, index) => (
320 <li key={pattern.id} className="flex items-center gap-2">
321 <Select
322 name="patternType"
323 value={pattern.type}
324 onValueChange={(type) =>
325 setPatterns((list) => list.map((row) => (row.id === pattern.id ? { ...row, type: type as BranchPattern["type"] } : row)))
326 }
327 >
328 <SelectTrigger aria-label={`Pattern ${index + 1} matches`} className="w-24 shrink-0">
329 <SelectValue />
330 </SelectTrigger>
331 <SelectContent>
332 <SelectItem value="branch">Branch</SelectItem>
333 <SelectItem value="tag">Tag</SelectItem>
334 </SelectContent>
335 </Select>
336 <div className="min-w-0 grow">
337 <Input
338 name="patternName"
339 defaultValue={pattern.name}
340 placeholder={pattern.type === "tag" ? "v*" : "release/*"}
341 aria-label={`Pattern ${index + 1}`}
342 maxLength={255}
343 />
344 </div>
345 <RemoveRow
346 label={`Remove pattern ${index + 1}`}
347 onClick={() => setPatterns((list) => list.filter((row) => row.id !== pattern.id))}
348 />
349 </li>
350 ))}
351 </ul>
352 <Button
353 type="button"
354 variant="outline"
355 onClick={() => setPatterns((list) => [...list, ...rows<BranchPattern>([{ name: "", type: "branch" }])])}
356 >
357 <Plus size={14} />
358 Add a pattern
359 </Button>
360 </Card>
361 )}
362 </Section>
363
364 <Section title="Admins" about="Whether people with the Admin role may let jobs through themselves.">
365 <CheckboxOption
366 name="adminsBypass"
367 defaultChecked={environment.adminsBypass}
368 label="Allow admins to bypass these rules"
369 description="Someone with the Admin role may approve without being a reviewer, which also skips the wait timer."
370 />
371 </Section>
372
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 name="intent" value="save" pending="Saving…">
375 {environment.protected ? "Save rules" : "Add rules"}
376 </SubmitButton>
377 {saved && <span className="text-sm text-muted">Saved.</span>}
378 <ErrorText>{error}</ErrorText>
379 {environment.updatedBy && environment.updatedAt && !saved && !error && (
380 <span className="text-xs text-faint">
381 Last changed by <span className="font-mono">{environment.updatedBy}</span> <TimeAgo at={environment.updatedAt} />
382 </span>
383 )}
384 </div>
385 </Form>
386
387 {environment.protected && <RemoveRules name={environment.name} />}
388 </div>
389 );
390}
391
392function RemoveRow({ label, onClick }: { label: string; onClick: () => void }) {
393 return (
394 <Hint label={label}>
395 <Button
396 type="button"
397 aria-label={label}
398 onClick={onClick}
399 variant="ghost"
400 size="icon"
401 className="hover:text-danger"
402 >
403 <Trash2 size={14} />
404 </Button>
405 </Hint>
406 );
407}
408
409function RemoveRules({ name }: { name: string }) {
410 return (
411 <Section title="Remove rules" about="Jobs that deploy here start at once again. The environment's secrets stay.">
412 <div>
413 <AlertDialog>
414 <AlertDialogTrigger asChild>
415 <Button type="button" variant="destructive">
416 <Trash2 size={14} />
417 Remove rules
418 </Button>
419 </AlertDialogTrigger>
420 <AlertDialogContent>
421 <Form method="post" className="grid gap-4">
422 <input type="hidden" name="intent" value="delete" />
423 <input type="hidden" name="environment" value={name} />
424 <AlertDialogHeader>
425 <AlertDialogTitle>Remove the rules for {name}?</AlertDialogTitle>
426 <AlertDialogDescription>
427 Its reviewers, wait timer and branch limits are given up, and jobs that name it no longer wait. Its
428 secrets stay.
429 </AlertDialogDescription>
430 </AlertDialogHeader>
431 <AlertDialogFooter>
432 <AlertDialogCancel>Cancel</AlertDialogCancel>
433 <AlertDialogAction asChild>
434 <button type="submit">
435 <Trash2 size={14} />
436 Remove rules
437 </button>
438 </AlertDialogAction>
439 </AlertDialogFooter>
440 </Form>
441 </AlertDialogContent>
442 </AlertDialog>
443 </div>
444 </Section>
445 );
446}