| 1 | import { ArrowLeft, ChevronRight, Layers, Plus, Trash2, Users } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { Form, Link, redirect } from "react-router"; |
| 4 | |
| 5 | import { MAX_ENVIRONMENT_REVIEWERS, MAX_WAIT_MINUTES } from "@g1t/contracts"; |
| 6 | import type { BranchPattern, Environment, EnvironmentReviewer } from "@g1t/contracts"; |
| 7 | |
| 8 | import type { Route } from "./+types/settings-environments"; |
| 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; |
| 10 | import { SettingsSection as Section } from "../../components/settings-section"; |
| 11 | import { EmptyState, ErrorText, Input, SubmitButton, TimeAgo } from "../../components/ui"; |
| 12 | import { |
| 13 | AlertDialog, |
| 14 | AlertDialogAction, |
| 15 | AlertDialogCancel, |
| 16 | AlertDialogContent, |
| 17 | AlertDialogDescription, |
| 18 | AlertDialogFooter, |
| 19 | AlertDialogHeader, |
| 20 | AlertDialogTitle, |
| 21 | AlertDialogTrigger, |
| 22 | } from "../../components/ui/alert-dialog"; |
| 23 | import { Badge } from "../../components/ui/badge"; |
| 24 | import { Button } from "../../components/ui/button"; |
| 25 | import { Card } from "../../components/ui/card"; |
| 26 | import { CheckboxOption } from "../../components/ui/checkbox"; |
| 27 | import { Hint } from "../../components/ui/hint"; |
| 28 | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; |
| 29 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; |
| 30 | import { environmentFromForm, environmentName, environmentSummary } from "../../lib/environments"; |
| 31 | import { page } from "../../lib/meta"; |
| 32 | import { actions } from "../../lib/services.server"; |
| 33 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; |
| 34 | import { requireCapability, requireInsider } from "../../lib/access.server"; |
| 35 | |
| 36 | export 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. */ |
| 41 | function 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 | |
| 56 | export 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 | |
| 70 | export 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 | |
| 89 | export 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 | |
| 104 | function 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. */ |
| 178 | type Row<T> = T & { id: number }; |
| 179 | let nextRow = 0; |
| 180 | const rows = <T,>(items: T[]): Row<T>[] => items.map((item) => ({ ...item, id: nextRow++ })); |
| 181 | |
| 182 | function 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 | |
| 360 | function 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 | |
| 370 | function 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 | } |