| 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} base={base} badName={badName} /> |
| 99 | )} |
| 100 | </div> |
| 101 | ); |
| 102 | } |
| 103 | |
| 104 | function 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. */ |
| 181 | type Row<T> = T & { id: number }; |
| 182 | let nextRow = 0; |
| 183 | const rows = <T,>(items: T[]): Row<T>[] => items.map((item) => ({ ...item, id: nextRow++ })); |
| 184 | |
| 185 | function 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 | |
| 392 | function 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 | |
| 409 | function 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 | } |