Rules on the site: rulesets for a repository and a workspace, and the merge box
- Settings → Rules for a repository: its rulesets and the workspace's that hold in it, what holds for any branch or tag (every rule, where it comes from, who may bypass it), and Insights: 30 days of evaluations, blocked, would block and bypassed, the rules broken most, and each push and merge as the rules judged it. - A ruleset's page: name, targets (branches or tags, patterns with the default branch and all), enforcement with evaluate as a dry run, the repositories a workspace's selects, the bypass list (g1t only when listed, with a warning), and every rule with its parameters, each for everyone, agents' changes or people's: required checks pinned to an integration or held to some paths, merge windows with weekly hours, freezes (one click freezes until lifted) and exceptions, confidence thresholds, cost caps, sensitive paths by team, and the rest. Export and import as JSON; delete. - The same for a workspace under its Settings → Rules, owners editing. - Branches and merging keeps how g1t's agents work and CODEOWNERS, and shows what holds for the default branch with a link to Rules; the protection toggles moved there. - The merge box lists each rule not met with its ruleset and how to meet it, what the viewer may bypass (a box to bypass, recorded), and what rulesets in evaluate would refuse; the button says why it cannot merge. - @g1t/contracts rules.ts mirrors the Rust types; the site's lib/rules.ts holds how each rule is shown and set up, with tests.
| 10 | 10 | guardrails: { title: "Guardrails", about: "What agents may reach, run and spend while they work here." }, | |
| 11 | 11 | repository: { title: "Repository", about: "Its name, details and default branch, who can see it, and archiving, moving or deleting it." }, | |
| 12 | 12 | access: { title: "Access", about: "Who can see and change the repository, with which role, and invitations to it." }, | |
| 13 | − | branches: { title: "Branches and merging", about: "Protection for the default branch, what a pull request needs before it merges, and what agents do." }, | |
| 13 | + | branches: { title: "Branches and merging", about: "How pull requests merge, what g1t's agents do with theirs, and who owns which files." }, | |
| 14 | + | rules: { title: "Rules", about: "Rulesets: what may happen to branches and tags, what a pull request needs before it merges, and how the rules judged each push and merge." }, | |
| 14 | 15 | secrets: { title: "Secrets and variables", about: "Values workflows, builds and deployments read at run time." }, | |
| 15 | 16 | runners: { title: "Runners", about: "Machines of your own for this project's workflow jobs, and where its agents' work runs." }, | |
| 16 | 17 | webhooks: { title: "Webhooks", about: "Addresses g1t calls when something happens in the project." }, |
| 1 | − | /** | |
| 2 | − | * Choosing the checks a branch requires: every name reported on the | |
| 3 | − | * repository's commits lately, with the events it was reported for, ticked | |
| 4 | − | * when it is required, and a field for a name nothing has reported yet. | |
| 5 | − | * Posts each ticked name as `requiredChecks`. | |
| 6 | − | */ | |
| 7 | − | import { Plus, X } from "lucide-react"; | |
| 8 | − | import { useState } from "react"; | |
| 9 | − | ||
| 10 | − | import type { SeenCheck } from "@g1t/contracts"; | |
| 11 | − | ||
| 12 | − | import { CheckboxOption } from "./ui/checkbox"; | |
| 13 | − | import { TimeAgo } from "./ui"; | |
| 14 | − | ||
| 15 | − | /** The events a workflow reports for that matter to a merge, first. */ | |
| 16 | − | const EVENT_ORDER = ["pull_request", "merge_group", "pull_request_target", "push"]; | |
| 17 | − | ||
| 18 | − | function events(list: string[]): string { | |
| 19 | − | const sorted = [...list].sort((a, b) => { | |
| 20 | − | const ia = EVENT_ORDER.indexOf(a); | |
| 21 | − | const ib = EVENT_ORDER.indexOf(b); | |
| 22 | − | return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.localeCompare(b); | |
| 23 | − | }); | |
| 24 | − | return sorted.join(", "); | |
| 25 | − | } | |
| 26 | − | ||
| 27 | − | /** What to say under a name: where it was seen, and whether that is enough to require it. */ | |
| 28 | − | function about(seen: SeenCheck | undefined, mergeQueue: boolean) { | |
| 29 | − | if (!seen) return <>Not reported in the last 30 days. Until something reports it, nothing can merge.</>; | |
| 30 | − | const onPulls = seen.events.length === 0 || seen.events.some((event) => event.startsWith("pull_request")); | |
| 31 | − | const onQueue = seen.events.length === 0 || seen.events.includes("merge_group"); | |
| 32 | − | return ( | |
| 33 | − | <> | |
| 34 | − | {seen.events.length > 0 ? `Reported for ${events(seen.events)}` : "Reported by a tool other than a workflow"} ·{" "} | |
| 35 | − | <TimeAgo at={seen.lastSeen} /> | |
| 36 | − | {!onPulls && <span className="text-warn"> · not on pull requests, so it would hold every merge</span>} | |
| 37 | − | {onPulls && mergeQueue && !onQueue && <span className="text-warn"> · not on merge_group, so the queue cannot pass</span>} | |
| 38 | − | </> | |
| 39 | − | ); | |
| 40 | − | } | |
| 41 | − | ||
| 42 | − | export function RequiredChecksPicker({ | |
| 43 | − | required, | |
| 44 | − | seen, | |
| 45 | − | mergeQueue, | |
| 46 | − | disabled, | |
| 47 | − | }: { | |
| 48 | − | required: string[]; | |
| 49 | − | seen: SeenCheck[]; | |
| 50 | − | mergeQueue: boolean; | |
| 51 | − | disabled?: boolean; | |
| 52 | − | }) { | |
| 53 | − | const [added, setAdded] = useState<string[]>([]); | |
| 54 | − | const [typed, setTyped] = useState(""); | |
| 55 | − | const known = (name: string, list: string[]) => list.some((other) => other.toLowerCase() === name.toLowerCase()); | |
| 56 | − | // Required ones first, as they are; then the rest seen lately; then what was typed. | |
| 57 | − | const names = [ | |
| 58 | − | ...required, | |
| 59 | − | ...seen.map((check) => check.name).filter((name) => !known(name, required)), | |
| 60 | − | ...added.filter((name) => !known(name, required) && !seen.some((check) => check.name.toLowerCase() === name.toLowerCase())), | |
| 61 | − | ]; | |
| 62 | − | const add = () => { | |
| 63 | − | const name = typed.trim(); | |
| 64 | − | if (name && !known(name, names)) setAdded([...added, name]); | |
| 65 | − | setTyped(""); | |
| 66 | − | }; | |
| 67 | − | return ( | |
| 68 | − | <div className="rounded-xl border border-line bg-surface p-4"> | |
| 69 | − | <p className="text-sm font-medium">Require status checks to pass before merging</p> | |
| 70 | − | <p className="mt-1 text-sm text-muted"> | |
| 71 | − | A pull request merges only once each check ticked here has passed on its latest commit: for people and agents | |
| 72 | − | alike, by hand, by auto-merge and through the queue. A workflow reports a check named after it. | |
| 73 | − | </p> | |
| 74 | − | {names.length === 0 ? ( | |
| 75 | − | <p className="mt-3 rounded-lg border border-dashed border-line px-3 py-3 text-sm text-faint"> | |
| 76 | − | No checks have been reported on this repository in the last 30 days. | |
| 77 | − | </p> | |
| 78 | − | ) : ( | |
| 79 | − | <ul className="mt-3 divide-y divide-line rounded-lg border border-line bg-bg"> | |
| 80 | − | {names.map((name) => { | |
| 81 | − | const seenCheck = seen.find((check) => check.name.toLowerCase() === name.toLowerCase()); | |
| 82 | − | const isAdded = known(name, added) && !known(name, required); | |
| 83 | − | return ( | |
| 84 | − | <li key={name} className="flex items-start gap-2 px-3 py-2.5"> | |
| 85 | − | <CheckboxOption | |
| 86 | − | className="min-w-0 grow" | |
| 87 | − | name="requiredChecks" | |
| 88 | − | value={name} | |
| 89 | − | defaultChecked={known(name, required) || isAdded} | |
| 90 | − | disabled={disabled} | |
| 91 | − | label={<span className="font-mono text-[0.8125rem]">{name}</span>} | |
| 92 | − | description={about(seenCheck, mergeQueue)} | |
| 93 | − | /> | |
| 94 | − | {isAdded && ( | |
| 95 | − | <button | |
| 96 | − | type="button" | |
| 97 | − | aria-label={`Remove ${name}`} | |
| 98 | − | onClick={() => setAdded(added.filter((other) => other !== name))} | |
| 99 | − | className="rounded-md p-1 text-faint hover:bg-raised hover:text-fg" | |
| 100 | − | > | |
| 101 | − | <X size={13} /> | |
| 102 | − | </button> | |
| 103 | − | )} | |
| 104 | − | </li> | |
| 105 | − | ); | |
| 106 | − | })} | |
| 107 | − | </ul> | |
| 108 | − | )} | |
| 109 | − | <div className="mt-3 flex flex-wrap gap-2"> | |
| 110 | − | <input | |
| 111 | − | value={typed} | |
| 112 | − | onChange={(event) => setTyped(event.target.value)} | |
| 113 | − | onKeyDown={(event) => { | |
| 114 | − | if (event.key === "Enter") { | |
| 115 | − | event.preventDefault(); | |
| 116 | − | add(); | |
| 117 | − | } | |
| 118 | − | }} | |
| 119 | − | disabled={disabled} | |
| 120 | − | maxLength={100} | |
| 121 | − | placeholder="Another check, by name" | |
| 122 | − | aria-label="Require another check, by name" | |
| 123 | − | className="min-w-0 grow rounded-md border border-line bg-bg px-3 py-1.5 font-mono text-sm placeholder:font-sans placeholder:text-faint focus:border-line-strong focus:outline-none sm:max-w-xs" | |
| 124 | − | /> | |
| 125 | − | <button | |
| 126 | − | type="button" | |
| 127 | − | onClick={add} | |
| 128 | − | disabled={disabled || typed.trim() === ""} | |
| 129 | − | className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 hover:border-line-strong hover:bg-raised disabled:opacity-50" | |
| 130 | − | > | |
| 131 | − | <Plus size={13} /> | |
| 132 | − | Add | |
| 133 | − | </button> | |
| 134 | − | </div> | |
| 135 | − | </div> | |
| 136 | − | ); | |
| 137 | − | } |
| 1 | + | /** | |
| 2 | + | * Rulesets on the site: the list, the form that creates and changes one | |
| 3 | + | * (every rule with its parameters), the rules that hold for one branch, | |
| 4 | + | * how the rules judged pushes and merges, and the violations a pull | |
| 5 | + | * request's merge box lists. Used by a repository's Settings → Rules and a | |
| 6 | + | * workspace's. | |
| 7 | + | */ | |
| 8 | + | import { | |
| 9 | + | Bot, | |
| 10 | + | Download, | |
| 11 | + | FileUp, | |
| 12 | + | GitBranch, | |
| 13 | + | Plus, | |
| 14 | + | ShieldAlert, | |
| 15 | + | ShieldCheck, | |
| 16 | + | ShieldOff, | |
| 17 | + | Tag, | |
| 18 | + | Trash2, | |
| 19 | + | TriangleAlert, | |
| 20 | + | Users, | |
| 21 | + | X, | |
| 22 | + | } from "lucide-react"; | |
| 23 | + | import { type ReactNode, useMemo, useRef, useState } from "react"; | |
| 24 | + | import { Form, Link } from "react-router"; | |
| 25 | + | ||
| 26 | + | import type { | |
| 27 | + | AppliesTo, | |
| 28 | + | BypassActor, | |
| 29 | + | BypassActorKind, | |
| 30 | + | ConfidenceLevel, | |
| 31 | + | EffectiveRules, | |
| 32 | + | Enforcement, | |
| 33 | + | Evaluation, | |
| 34 | + | EvaluationPage, | |
| 35 | + | Level, | |
| 36 | + | MergeMethod, | |
| 37 | + | PatternOperator, | |
| 38 | + | RuleEntry, | |
| 39 | + | RuleType, | |
| 40 | + | Ruleset, | |
| 41 | + | RulesetSpec, | |
| 42 | + | SeenCheck, | |
| 43 | + | Violation, | |
| 44 | + | Weekday, | |
| 45 | + | } from "@g1t/contracts"; | |
| 46 | + | ||
| 47 | + | import { cn } from "../lib/cn"; | |
| 48 | + | import { | |
| 49 | + | ALL, | |
| 50 | + | DEFAULT_BRANCH, | |
| 51 | + | RULES, | |
| 52 | + | RULE_GROUPS, | |
| 53 | + | describeAppliesTo, | |
| 54 | + | describeBypassActor, | |
| 55 | + | exportRuleset, | |
| 56 | + | importRuleset, | |
| 57 | + | newRule, | |
| 58 | + | patternLabel, | |
| 59 | + | ruleInfo, | |
| 60 | + | targetSummary, | |
| 61 | + | } from "../lib/rules"; | |
| 62 | + | import { Button, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 63 | + | import { Badge } from "./ui/badge"; | |
| 64 | + | import { CheckboxOption } from "./ui/checkbox"; | |
| 65 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 66 | + | import { Input } from "./ui/input"; | |
| 67 | + | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 68 | + | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 69 | + | import { Switch } from "./ui/switch"; | |
| 70 | + | ||
| 71 | + | // --- Small pieces ----------------------------------------------------------- | |
| 72 | + | ||
| 73 | + | const ENFORCEMENT: Record<Enforcement, { label: string; about: string; tone: "accent" | "info" | "neutral" }> = { | |
| 74 | + | active: { label: "Active", about: "Its rules hold: what breaks them is refused.", tone: "accent" }, | |
| 75 | + | evaluate: { label: "Evaluate", about: "A dry run: nothing is refused, and what would have been is recorded in Insights.", tone: "info" }, | |
| 76 | + | disabled: { label: "Disabled", about: "Kept, but not evaluated.", tone: "neutral" }, | |
| 77 | + | }; | |
| 78 | + | ||
| 79 | + | export function EnforcementBadge({ enforcement }: { enforcement: Enforcement }) { | |
| 80 | + | const info = ENFORCEMENT[enforcement]; | |
| 81 | + | return <Badge tone={info.tone}>{info.label}</Badge>; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /** A list of patterns or names, as removable chips and a field to add one. */ | |
| 85 | + | export function PatternList({ | |
| 86 | + | values, | |
| 87 | + | onChange, | |
| 88 | + | placeholder, | |
| 89 | + | quick = [], | |
| 90 | + | disabled, | |
| 91 | + | label, | |
| 92 | + | mono = true, | |
| 93 | + | }: { | |
| 94 | + | values: string[]; | |
| 95 | + | onChange: (values: string[]) => void; | |
| 96 | + | placeholder: string; | |
| 97 | + | quick?: { value: string; label: string }[]; | |
| 98 | + | disabled?: boolean; | |
| 99 | + | label: string; | |
| 100 | + | mono?: boolean; | |
| 101 | + | }) { | |
| 102 | + | const [typed, setTyped] = useState(""); | |
| 103 | + | const add = (value: string) => { | |
| 104 | + | const trimmed = value.trim(); | |
| 105 | + | if (!trimmed || values.includes(trimmed)) return; | |
| 106 | + | onChange([...values, trimmed]); | |
| 107 | + | setTyped(""); | |
| 108 | + | }; | |
| 109 | + | return ( | |
| 110 | + | <div className="space-y-2"> | |
| 111 | + | {values.length > 0 && ( | |
| 112 | + | <ul className="flex flex-wrap gap-1.5" aria-label={label}> | |
| 113 | + | {values.map((value) => ( | |
| 114 | + | <li key={value} className="inline-flex max-w-full items-center gap-1 rounded-md border border-line bg-bg py-0.5 pr-1 pl-2 text-sm"> | |
| 115 | + | <span className={cn("truncate", mono && !value.startsWith("~") && "font-mono text-[0.8125rem]")}>{patternLabel(value)}</span> | |
| 116 | + | {!disabled && ( | |
| 117 | + | <button | |
| 118 | + | type="button" | |
| 119 | + | aria-label={`Remove ${patternLabel(value)}`} | |
| 120 | + | className="rounded p-0.5 text-faint hover:bg-surface hover:text-fg" | |
| 121 | + | onClick={() => onChange(values.filter((other) => other !== value))} | |
| 122 | + | > | |
| 123 | + | <X size={13} /> | |
| 124 | + | </button> | |
| 125 | + | )} | |
| 126 | + | </li> | |
| 127 | + | ))} | |
| 128 | + | </ul> | |
| 129 | + | )} | |
| 130 | + | {!disabled && ( | |
| 131 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 132 | + | <Input | |
| 133 | + | value={typed} | |
| 134 | + | aria-label={label} | |
| 135 | + | placeholder={placeholder} | |
| 136 | + | className="w-56 max-w-full font-mono text-[0.8125rem]" | |
| 137 | + | onChange={(event) => setTyped(event.target.value)} | |
| 138 | + | onKeyDown={(event) => { | |
| 139 | + | if (event.key === "Enter") { | |
| 140 | + | event.preventDefault(); | |
| 141 | + | add(typed); | |
| 142 | + | } | |
| 143 | + | }} | |
| 144 | + | /> | |
| 145 | + | <Button type="button" variant="quiet" onClick={() => add(typed)} disabled={!typed.trim()}> | |
| 146 | + | <Plus size={14} /> Add | |
| 147 | + | </Button> | |
| 148 | + | {quick | |
| 149 | + | .filter((option) => !values.includes(option.value)) | |
| 150 | + | .map((option) => ( | |
| 151 | + | <button | |
| 152 | + | key={option.value} | |
| 153 | + | type="button" | |
| 154 | + | className="rounded-full border border-dashed border-line px-2.5 py-1 text-xs text-muted hover:border-line-strong hover:text-fg" | |
| 155 | + | onClick={() => add(option.value)} | |
| 156 | + | > | |
| 157 | + | + {option.label} | |
| 158 | + | </button> | |
| 159 | + | ))} | |
| 160 | + | </div> | |
| 161 | + | )} | |
| 162 | + | </div> | |
| 163 | + | ); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | function Choice<T extends string>({ | |
| 167 | + | value, | |
| 168 | + | options, | |
| 169 | + | onChange, | |
| 170 | + | label, | |
| 171 | + | disabled, | |
| 172 | + | className, | |
| 173 | + | }: { | |
| 174 | + | value: T; | |
| 175 | + | options: { value: T; label: string }[]; | |
| 176 | + | onChange: (value: T) => void; | |
| 177 | + | label: string; | |
| 178 | + | disabled?: boolean; | |
| 179 | + | className?: string; | |
| 180 | + | }) { | |
| 181 | + | return ( | |
| 182 | + | <Select value={value} onValueChange={(next) => onChange(next as T)} disabled={disabled}> | |
| 183 | + | <SelectTrigger size="sm" aria-label={label} className={cn("w-auto min-w-28", className)}> | |
| 184 | + | <SelectValue /> | |
| 185 | + | </SelectTrigger> | |
| 186 | + | <SelectContent> | |
| 187 | + | {options.map((option) => ( | |
| 188 | + | <SelectItem key={option.value} value={option.value}> | |
| 189 | + | {option.label} | |
| 190 | + | </SelectItem> | |
| 191 | + | ))} | |
| 192 | + | </SelectContent> | |
| 193 | + | </Select> | |
| 194 | + | ); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | function Toggle({ | |
| 198 | + | checked, | |
| 199 | + | onChange, | |
| 200 | + | title, | |
| 201 | + | about, | |
| 202 | + | disabled, | |
| 203 | + | }: { | |
| 204 | + | checked: boolean; | |
| 205 | + | onChange: (checked: boolean) => void; | |
| 206 | + | title: string; | |
| 207 | + | about?: ReactNode; | |
| 208 | + | disabled?: boolean; | |
| 209 | + | }) { | |
| 210 | + | return ( | |
| 211 | + | <label className="flex cursor-pointer items-start justify-between gap-4 py-1.5"> | |
| 212 | + | <span className="min-w-0"> | |
| 213 | + | <span className="block text-sm text-fg">{title}</span> | |
| 214 | + | {about && <span className="mt-0.5 block text-xs text-muted">{about}</span>} | |
| 215 | + | </span> | |
| 216 | + | <Switch size="sm" checked={checked} onCheckedChange={(next) => onChange(next === true)} disabled={disabled} className="mt-0.5" aria-label={title} /> | |
| 217 | + | </label> | |
| 218 | + | ); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | function NumberField({ | |
| 222 | + | value, | |
| 223 | + | onChange, | |
| 224 | + | label, | |
| 225 | + | min, | |
| 226 | + | max, | |
| 227 | + | step = 1, | |
| 228 | + | suffix, | |
| 229 | + | disabled, | |
| 230 | + | }: { | |
| 231 | + | value: number; | |
| 232 | + | onChange: (value: number) => void; | |
| 233 | + | label: string; | |
| 234 | + | min: number; | |
| 235 | + | max: number; | |
| 236 | + | step?: number; | |
| 237 | + | suffix?: string; | |
| 238 | + | disabled?: boolean; | |
| 239 | + | }) { | |
| 240 | + | return ( | |
| 241 | + | <label className="flex items-center justify-between gap-4 py-1.5"> | |
| 242 | + | <span className="text-sm text-fg">{label}</span> | |
| 243 | + | <span className="flex items-center gap-2"> | |
| 244 | + | <Input | |
| 245 | + | type="number" | |
| 246 | + | min={min} | |
| 247 | + | max={max} | |
| 248 | + | step={step} | |
| 249 | + | value={Number.isFinite(value) ? value : ""} | |
| 250 | + | disabled={disabled} | |
| 251 | + | className="w-24 text-right" | |
| 252 | + | onChange={(event) => onChange(Number(event.target.value))} | |
| 253 | + | /> | |
| 254 | + | {suffix && <span className="text-sm text-muted">{suffix}</span>} | |
| 255 | + | </span> | |
| 256 | + | </label> | |
| 257 | + | ); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // --- Rule parameters ---------------------------------------------------------- | |
| 261 | + | ||
| 262 | + | const OPERATORS: { value: PatternOperator; label: string }[] = [ | |
| 263 | + | { value: "starts_with", label: "Starts with" }, | |
| 264 | + | { value: "ends_with", label: "Ends with" }, | |
| 265 | + | { value: "contains", label: "Contains" }, | |
| 266 | + | { value: "regex", label: "Matches regex" }, | |
| 267 | + | ]; | |
| 268 | + | const LEVELS: { value: ConfidenceLevel; label: string }[] = [ | |
| 269 | + | { value: "low", label: "Low" }, | |
| 270 | + | { value: "medium", label: "Medium" }, | |
| 271 | + | { value: "high", label: "High" }, | |
| 272 | + | ]; | |
| 273 | + | const DAYS: Weekday[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; | |
| 274 | + | const DAY_LABEL: Record<Weekday, string> = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" }; | |
| 275 | + | ||
| 276 | + | /** An RFC 3339 time as a datetime-local value, in the viewer's zone. */ | |
| 277 | + | function toLocal(iso: string | null | undefined): string { | |
| 278 | + | if (!iso) return ""; | |
| 279 | + | const date = new Date(iso); | |
| 280 | + | if (Number.isNaN(date.getTime())) return ""; | |
| 281 | + | const offset = date.getTimezoneOffset() * 60_000; | |
| 282 | + | return new Date(date.getTime() - offset).toISOString().slice(0, 16); | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | function fromLocal(value: string): string | null { | |
| 286 | + | if (!value) return null; | |
| 287 | + | const date = new Date(value); | |
| 288 | + | return Number.isNaN(date.getTime()) ? null : date.toISOString(); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | type Periods = { start: string; end?: string | null; reason: string }[]; | |
| 292 | + | ||
| 293 | + | function PeriodList({ | |
| 294 | + | periods, | |
| 295 | + | onChange, | |
| 296 | + | label, | |
| 297 | + | disabled, | |
| 298 | + | allowOpen, | |
| 299 | + | }: { | |
| 300 | + | periods: Periods; | |
| 301 | + | onChange: (periods: Periods) => void; | |
| 302 | + | label: string; | |
| 303 | + | disabled?: boolean; | |
| 304 | + | allowOpen?: boolean; | |
| 305 | + | }) { | |
| 306 | + | return ( | |
| 307 | + | <div className="space-y-2"> | |
| 308 | + | {periods.map((period, index) => ( | |
| 309 | + | <div key={index} className="flex flex-wrap items-center gap-2 rounded-lg border border-line p-2"> | |
| 310 | + | <Input | |
| 311 | + | type="datetime-local" | |
| 312 | + | aria-label={`${label} ${index + 1} starts`} | |
| 313 | + | value={toLocal(period.start)} | |
| 314 | + | disabled={disabled} | |
| 315 | + | className="w-auto" | |
| 316 | + | onChange={(event) => onChange(periods.map((one, at) => (at === index ? { ...one, start: fromLocal(event.target.value) ?? one.start } : one)))} | |
| 317 | + | /> | |
| 318 | + | <span className="text-sm text-muted">to</span> | |
| 319 | + | <Input | |
| 320 | + | type="datetime-local" | |
| 321 | + | aria-label={`${label} ${index + 1} ends`} | |
| 322 | + | value={toLocal(period.end)} | |
| 323 | + | disabled={disabled} | |
| 324 | + | className="w-auto" | |
| 325 | + | onChange={(event) => onChange(periods.map((one, at) => (at === index ? { ...one, end: fromLocal(event.target.value) } : one)))} | |
| 326 | + | /> | |
| 327 | + | <Input | |
| 328 | + | aria-label={`${label} ${index + 1} reason`} | |
| 329 | + | placeholder="Why" | |
| 330 | + | value={period.reason} | |
| 331 | + | disabled={disabled} | |
| 332 | + | className="w-40 grow" | |
| 333 | + | onChange={(event) => onChange(periods.map((one, at) => (at === index ? { ...one, reason: event.target.value } : one)))} | |
| 334 | + | /> | |
| 335 | + | {!disabled && ( | |
| 336 | + | <button type="button" aria-label={`Remove ${label.toLowerCase()} ${index + 1}`} className="rounded p-1 text-faint hover:text-fg" onClick={() => onChange(periods.filter((_, at) => at !== index))}> | |
| 337 | + | <X size={14} /> | |
| 338 | + | </button> | |
| 339 | + | )} | |
| 340 | + | </div> | |
| 341 | + | ))} | |
| 342 | + | {!disabled && ( | |
| 343 | + | <div className="flex flex-wrap gap-2"> | |
| 344 | + | <Button | |
| 345 | + | type="button" | |
| 346 | + | variant="quiet" | |
| 347 | + | onClick={() => onChange([...periods, { start: new Date().toISOString(), end: new Date(Date.now() + 86_400_000).toISOString(), reason: "" }])} | |
| 348 | + | > | |
| 349 | + | <Plus size={14} /> {label} | |
| 350 | + | </Button> | |
| 351 | + | {allowOpen && ( | |
| 352 | + | <Button type="button" variant="quiet" onClick={() => onChange([...periods, { start: new Date().toISOString(), end: null, reason: "Incident" }])}> | |
| 353 | + | <ShieldAlert size={14} /> Freeze now, until lifted | |
| 354 | + | </Button> | |
| 355 | + | )} | |
| 356 | + | </div> | |
| 357 | + | )} | |
| 358 | + | </div> | |
| 359 | + | ); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /** The parameters of one rule, as fields. */ | |
| 363 | + | function Parameters({ | |
| 364 | + | entry, | |
| 365 | + | onChange, | |
| 366 | + | seen, | |
| 367 | + | disabled, | |
| 368 | + | }: { | |
| 369 | + | entry: RuleEntry; | |
| 370 | + | onChange: (entry: RuleEntry) => void; | |
| 371 | + | seen: SeenCheck[]; | |
| 372 | + | disabled?: boolean; | |
| 373 | + | }) { | |
| 374 | + | // Each case narrows `entry` and writes back its own parameters. | |
| 375 | + | const set = (parameters: object) => onChange({ ...entry, parameters } as RuleEntry); | |
| 376 | + | switch (entry.type) { | |
| 377 | + | case "pull_request": { | |
| 378 | + | const p = entry.parameters; | |
| 379 | + | const methods: MergeMethod[] = ["merge", "squash", "rebase"]; | |
| 380 | + | return ( | |
| 381 | + | <div className="divide-y divide-line"> | |
| 382 | + | <NumberField label="Required approvals" min={0} max={10} value={p.required_approvals} disabled={disabled} onChange={(value) => set({ ...p, required_approvals: value })} /> | |
| 383 | + | <Toggle title="An agent's approval counts" about="Off: approvals must come from people." checked={p.count_agent_approvals} disabled={disabled} onChange={(value) => set({ ...p, count_agent_approvals: value })} /> | |
| 384 | + | <Toggle title="Dismiss approvals when new commits are pushed" checked={p.dismiss_stale_reviews_on_push} disabled={disabled} onChange={(value) => set({ ...p, dismiss_stale_reviews_on_push: value })} /> | |
| 385 | + | <Toggle title="Require review from code owners" about="The owners of every file it changes, as CODEOWNERS names them, approve." checked={p.require_code_owner_review} disabled={disabled} onChange={(value) => set({ ...p, require_code_owner_review: value })} /> | |
| 386 | + | <Toggle title="Require approval of the most recent push" about="Someone other than whoever pushed last approves after that push." checked={p.require_last_push_approval} disabled={disabled} onChange={(value) => set({ ...p, require_last_push_approval: value })} /> | |
| 387 | + | {p.allow_direct_pushes && ( | |
| 388 | + | <Toggle | |
| 389 | + | title="Still allow pushes straight to the branch" | |
| 390 | + | about="Kept from branch protection that did not require pull requests. Turn it off to require them." | |
| 391 | + | checked={p.allow_direct_pushes} | |
| 392 | + | disabled={disabled} | |
| 393 | + | onChange={(value) => set({ ...p, allow_direct_pushes: value })} | |
| 394 | + | /> | |
| 395 | + | )} | |
| 396 | + | <div className="py-2"> | |
| 397 | + | <p className="text-sm text-fg">Allowed merge methods</p> | |
| 398 | + | <p className="mt-0.5 text-xs text-muted">None ticked allows every one. g1t merges by landing the branch as it is (merge).</p> | |
| 399 | + | <div className="mt-2 flex flex-wrap gap-4"> | |
| 400 | + | {methods.map((method) => ( | |
| 401 | + | <CheckboxOption | |
| 402 | + | key={method} | |
| 403 | + | label={method[0]!.toUpperCase() + method.slice(1)} | |
| 404 | + | checked={p.allowed_merge_methods.includes(method)} | |
| 405 | + | disabled={disabled} | |
| 406 | + | onCheckedChange={(checked) => | |
| 407 | + | set({ | |
| 408 | + | ...p, | |
| 409 | + | allowed_merge_methods: checked | |
| 410 | + | ? [...p.allowed_merge_methods, method] | |
| 411 | + | : p.allowed_merge_methods.filter((other) => other !== method), | |
| 412 | + | }) | |
| 413 | + | } | |
| 414 | + | /> | |
| 415 | + | ))} | |
| 416 | + | </div> | |
| 417 | + | </div> | |
| 418 | + | </div> | |
| 419 | + | ); | |
| 420 | + | } | |
| 421 | + | case "required_status_checks": { | |
| 422 | + | const p = entry.parameters; | |
| 423 | + | const names = p.checks.map((check) => check.context); | |
| 424 | + | const suggestions = seen.map((check) => check.name).filter((name) => !names.some((have) => have.toLowerCase() === name.toLowerCase())); | |
| 425 | + | return ( | |
| 426 | + | <div className="space-y-3"> | |
| 427 | + | <div> | |
| 428 | + | <p className="mb-2 text-sm text-fg">Checks</p> | |
| 429 | + | <ul className="space-y-1.5"> | |
| 430 | + | {p.checks.map((check, index) => ( | |
| 431 | + | <li key={check.context} className="flex flex-wrap items-center gap-2 rounded-lg border border-line px-2.5 py-1.5"> | |
| 432 | + | <span className="grow font-mono text-[0.8125rem]">{check.context}</span> | |
| 433 | + | <Choice | |
| 434 | + | label={`Where ${check.context} comes from`} | |
| 435 | + | value={check.integration ?? "any"} | |
| 436 | + | disabled={disabled} | |
| 437 | + | options={[ | |
| 438 | + | { value: "any", label: "Any source" }, | |
| 439 | + | { value: "actions", label: "Workflows" }, | |
| 440 | + | { value: "deployments", label: "Deployments" }, | |
| 441 | + | { value: "security", label: "Security" }, | |
| 442 | + | { value: "g1t", label: "g1t" }, | |
| 443 | + | ]} | |
| 444 | + | onChange={(value) => | |
| 445 | + | set({ | |
| 446 | + | ...p, | |
| 447 | + | checks: p.checks.map((one, at) => | |
| 448 | + | at === index ? (value === "any" ? { context: one.context } : { context: one.context, integration: value }) : one, | |
| 449 | + | ), | |
| 450 | + | }) | |
| 451 | + | } | |
| 452 | + | /> | |
| 453 | + | {!disabled && ( | |
| 454 | + | <button type="button" aria-label={`Stop requiring ${check.context}`} className="rounded p-1 text-faint hover:text-fg" onClick={() => set({ ...p, checks: p.checks.filter((_, at) => at !== index) })}> | |
| 455 | + | <X size={14} /> | |
| 456 | + | </button> | |
| 457 | + | )} | |
| 458 | + | </li> | |
| 459 | + | ))} | |
| 460 | + | </ul> | |
| 461 | + | <div className="mt-2"> | |
| 462 | + | <PatternList | |
| 463 | + | label="Add a required check" | |
| 464 | + | values={[]} | |
| 465 | + | placeholder="CI" | |
| 466 | + | mono={false} | |
| 467 | + | disabled={disabled} | |
| 468 | + | quick={suggestions.slice(0, 8).map((name) => ({ value: name, label: name }))} | |
| 469 | + | onChange={(added) => set({ ...p, checks: [...p.checks, ...added.map((context) => ({ context }))] })} | |
| 470 | + | /> | |
| 471 | + | </div> | |
| 472 | + | </div> | |
| 473 | + | <div className="divide-y divide-line"> | |
| 474 | + | <Toggle title="Require branches to be up to date before merging" about="What merges is exactly what was checked." checked={p.strict} disabled={disabled} onChange={(value) => set({ ...p, strict: value })} /> | |
| 475 | + | <Toggle title="Let someone who may merge bypass these checks" about="They tick a box as they merge, and the pull request says who did." checked={p.allow_bypass_on_merge} disabled={disabled} onChange={(value) => set({ ...p, allow_bypass_on_merge: value })} /> | |
| 476 | + | </div> | |
| 477 | + | <div> | |
| 478 | + | <p className="text-sm text-fg">Only when these paths change</p> | |
| 479 | + | <p className="mb-2 text-xs text-muted">Empty: always required.</p> | |
| 480 | + | <PatternList label="Paths" values={p.paths} placeholder="infra/**" disabled={disabled} onChange={(paths) => set({ ...p, paths })} /> | |
| 481 | + | </div> | |
| 482 | + | </div> | |
| 483 | + | ); | |
| 484 | + | } | |
| 485 | + | case "merge_queue": { | |
| 486 | + | const p = entry.parameters; | |
| 487 | + | return ( | |
| 488 | + | <div className="divide-y divide-line"> | |
| 489 | + | <NumberField label="Pull requests tested at once" min={1} max={20} value={p.max_entries_to_build} disabled={disabled} onChange={(value) => set({ ...p, max_entries_to_build: value })} /> | |
| 490 | + | <NumberField label="Smallest batch to start" min={1} max={20} value={p.min_entries_to_merge} disabled={disabled} onChange={(value) => set({ ...p, min_entries_to_merge: value })} /> | |
| 491 | + | <NumberField label="Wait for a batch to fill, at most" min={0} max={360} value={p.min_entries_wait_minutes} suffix="min" disabled={disabled} onChange={(value) => set({ ...p, min_entries_wait_minutes: value })} /> | |
| 492 | + | <NumberField label="Check timeout" min={5} max={360} value={p.check_response_timeout_minutes} suffix="min" disabled={disabled} onChange={(value) => set({ ...p, check_response_timeout_minutes: value })} /> | |
| 493 | + | </div> | |
| 494 | + | ); | |
| 495 | + | } | |
| 496 | + | case "required_deployments": | |
| 497 | + | return ( | |
| 498 | + | <PatternList | |
| 499 | + | label="Environments" | |
| 500 | + | values={entry.parameters.environments} | |
| 501 | + | placeholder="preview, or a project's slug" | |
| 502 | + | quick={[{ value: "preview", label: "preview" }]} | |
| 503 | + | disabled={disabled} | |
| 504 | + | onChange={(environments) => set({ environments })} | |
| 505 | + | /> | |
| 506 | + | ); | |
| 507 | + | case "commit_message_pattern": | |
| 508 | + | case "commit_author_email_pattern": | |
| 509 | + | case "committer_email_pattern": | |
| 510 | + | case "branch_name_pattern": | |
| 511 | + | case "tag_name_pattern": { | |
| 512 | + | const p = entry.parameters; | |
| 513 | + | return ( | |
| 514 | + | <div className="space-y-2"> | |
| 515 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 516 | + | <Choice label="How it compares" value={p.operator} options={OPERATORS} disabled={disabled} onChange={(operator) => set({ ...p, operator })} /> | |
| 517 | + | <Input aria-label="Pattern" value={p.pattern} disabled={disabled} placeholder={p.operator === "regex" ? "^(feat|fix)(\\(.+\\))?: " : "@acme.com"} className="w-64 grow font-mono text-[0.8125rem]" onChange={(event) => set({ ...p, pattern: event.target.value })} /> | |
| 518 | + | </div> | |
| 519 | + | <div className="flex flex-wrap items-center gap-4"> | |
| 520 | + | <CheckboxOption label="Must not match" checked={p.negate} disabled={disabled} onCheckedChange={(checked) => set({ ...p, negate: checked === true })} /> | |
| 521 | + | <Input aria-label="What people call it" value={p.name} disabled={disabled} placeholder="Name it, e.g. Conventional commits" className="w-64 grow" onChange={(event) => set({ ...p, name: event.target.value })} /> | |
| 522 | + | </div> | |
| 523 | + | {p.operator === "regex" && <p className="text-xs text-muted">Runs on a linear-time engine: no look-around or back-references.</p>} | |
| 524 | + | </div> | |
| 525 | + | ); | |
| 526 | + | } | |
| 527 | + | case "file_path_restriction": | |
| 528 | + | return ( | |
| 529 | + | <PatternList | |
| 530 | + | label="Restricted paths" | |
| 531 | + | values={entry.parameters.restricted_file_paths} | |
| 532 | + | placeholder=".g1t/workflows/**" | |
| 533 | + | quick={[ | |
| 534 | + | { value: ".g1t/workflows/**", label: "Workflows" }, | |
| 535 | + | { value: "CODEOWNERS", label: "CODEOWNERS" }, | |
| 536 | + | ]} | |
| 537 | + | disabled={disabled} | |
| 538 | + | onChange={(restricted_file_paths) => set({ restricted_file_paths })} | |
| 539 | + | /> | |
| 540 | + | ); | |
| 541 | + | case "file_extension_restriction": | |
| 542 | + | return ( | |
| 543 | + | <PatternList | |
| 544 | + | label="Restricted extensions" | |
| 545 | + | values={entry.parameters.restricted_file_extensions} | |
| 546 | + | placeholder=".exe" | |
| 547 | + | quick={[ | |
| 548 | + | { value: ".exe", label: ".exe" }, | |
| 549 | + | { value: ".zip", label: ".zip" }, | |
| 550 | + | ]} | |
| 551 | + | disabled={disabled} | |
| 552 | + | onChange={(restricted_file_extensions) => set({ restricted_file_extensions })} | |
| 553 | + | /> | |
| 554 | + | ); | |
| 555 | + | case "max_file_size": | |
| 556 | + | return <NumberField label="Largest file" min={1} max={100} suffix="MB" value={entry.parameters.max_file_size_mb} disabled={disabled} onChange={(max_file_size_mb) => set({ max_file_size_mb })} />; | |
| 557 | + | case "max_file_path_length": | |
| 558 | + | return <NumberField label="Longest path" min={1} max={4096} suffix="characters" value={entry.parameters.max_file_path_length} disabled={disabled} onChange={(max_file_path_length) => set({ max_file_path_length })} />; | |
| 559 | + | case "max_files_changed": | |
| 560 | + | return <NumberField label="Most files one commit changes" min={1} max={100000} value={entry.parameters.max_files} disabled={disabled} onChange={(max_files) => set({ max_files })} />; | |
| 561 | + | case "confidence_threshold": { | |
| 562 | + | const p = entry.parameters; | |
| 563 | + | return ( | |
| 564 | + | <div className="divide-y divide-line"> | |
| 565 | + | <label className="flex items-center justify-between gap-4 py-1.5"> | |
| 566 | + | <span className="text-sm text-fg">Changes rated below</span> | |
| 567 | + | <Choice label="Lowest confidence that merges without people" value={p.minimum} options={LEVELS} disabled={disabled} onChange={(minimum) => set({ ...p, minimum })} /> | |
| 568 | + | </label> | |
| 569 | + | <NumberField label="need approvals from people" min={1} max={10} value={p.required_approvals} disabled={disabled} onChange={(required_approvals) => set({ ...p, required_approvals })} /> | |
| 570 | + | </div> | |
| 571 | + | ); | |
| 572 | + | } | |
| 573 | + | case "cost_cap": | |
| 574 | + | return <NumberField label="Most agents may spend on one pull request" min={0.5} max={10000} step={0.5} suffix="USD" value={entry.parameters.max_usd} disabled={disabled} onChange={(max_usd) => set({ max_usd })} />; | |
| 575 | + | case "path_review": { | |
| 576 | + | const p = entry.parameters; | |
| 577 | + | return ( | |
| 578 | + | <div className="space-y-2"> | |
| 579 | + | <PatternList label="Sensitive paths" values={p.paths} placeholder="infra/**" disabled={disabled} onChange={(paths) => set({ ...p, paths })} /> | |
| 580 | + | <NumberField label="Approvals from people" min={1} max={10} value={p.required_approvals} disabled={disabled} onChange={(required_approvals) => set({ ...p, required_approvals })} /> | |
| 581 | + | <label className="flex items-center justify-between gap-4 py-1.5"> | |
| 582 | + | <span className="text-sm text-fg">From the team</span> | |
| 583 | + | <Input aria-label="Team" value={p.team ?? ""} disabled={disabled} placeholder="Anyone, or workspace/team" className="w-56" onChange={(event) => set({ ...p, team: event.target.value || null })} /> | |
| 584 | + | </label> | |
| 585 | + | </div> | |
| 586 | + | ); | |
| 587 | + | } | |
| 588 | + | case "merge_window": { | |
| 589 | + | const p = entry.parameters; | |
| 590 | + | return ( | |
| 591 | + | <div className="space-y-4"> | |
| 592 | + | <label className="flex items-center justify-between gap-4"> | |
| 593 | + | <span className="text-sm text-fg">Time zone, as an offset from UTC</span> | |
| 594 | + | <Input aria-label="Time zone" value={p.time_zone} disabled={disabled} placeholder="UTC or +02:00" className="w-32" onChange={(event) => set({ ...p, time_zone: event.target.value })} /> | |
| 595 | + | </label> | |
| 596 | + | <div> | |
| 597 | + | <p className="text-sm text-fg">Weekly hours when merging is open</p> | |
| 598 | + | <p className="mb-2 text-xs text-muted">None: open whenever no freeze covers the moment.</p> | |
| 599 | + | <div className="space-y-2"> | |
| 600 | + | {p.windows.map((window, index) => ( | |
| 601 | + | <div key={index} className="flex flex-wrap items-center gap-2 rounded-lg border border-line p-2"> | |
| 602 | + | <div className="flex flex-wrap gap-1"> | |
| 603 | + | {DAYS.map((day) => { | |
| 604 | + | const on = window.days.includes(day); | |
| 605 | + | return ( | |
| 606 | + | <button | |
| 607 | + | key={day} | |
| 608 | + | type="button" | |
| 609 | + | aria-pressed={on} | |
| 610 | + | disabled={disabled} | |
| 611 | + | className={cn("rounded-md border px-2 py-1 text-xs", on ? "border-accent/50 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg")} | |
| 612 | + | onClick={() => | |
| 613 | + | set({ | |
| 614 | + | ...p, | |
| 615 | + | windows: p.windows.map((one, at) => | |
| 616 | + | at === index ? { ...one, days: on ? one.days.filter((other) => other !== day) : DAYS.filter((d) => d === day || one.days.includes(d)) } : one, | |
| 617 | + | ), | |
| 618 | + | }) | |
| 619 | + | } | |
| 620 | + | > | |
| 621 | + | {DAY_LABEL[day]} | |
| 622 | + | </button> | |
| 623 | + | ); | |
| 624 | + | })} | |
| 625 | + | </div> | |
| 626 | + | <Input type="time" aria-label="Opens" value={window.start} disabled={disabled} className="w-28" onChange={(event) => set({ ...p, windows: p.windows.map((one, at) => (at === index ? { ...one, start: event.target.value } : one)) })} /> | |
| 627 | + | <span className="text-sm text-muted">to</span> | |
| 628 | + | <Input type="time" aria-label="Closes" value={window.end} disabled={disabled} className="w-28" onChange={(event) => set({ ...p, windows: p.windows.map((one, at) => (at === index ? { ...one, end: event.target.value } : one)) })} /> | |
| 629 | + | {!disabled && ( | |
| 630 | + | <button type="button" aria-label={`Remove window ${index + 1}`} className="rounded p-1 text-faint hover:text-fg" onClick={() => set({ ...p, windows: p.windows.filter((_, at) => at !== index) })}> | |
| 631 | + | <X size={14} /> | |
| 632 | + | </button> | |
| 633 | + | )} | |
| 634 | + | </div> | |
| 635 | + | ))} | |
| 636 | + | {!disabled && ( | |
| 637 | + | <Button type="button" variant="quiet" onClick={() => set({ ...p, windows: [...p.windows, { days: ["mon", "tue", "wed", "thu", "fri"], start: "09:00", end: "17:00" }] })}> | |
| 638 | + | <Plus size={14} /> Weekly hours | |
| 639 | + | </Button> | |
| 640 | + | )} | |
| 641 | + | </div> | |
| 642 | + | </div> | |
| 643 | + | <div> | |
| 644 | + | <p className="mb-2 text-sm text-fg">Freezes: merging waits</p> | |
| 645 | + | <PeriodList label="Freeze" periods={p.freezes} allowOpen disabled={disabled} onChange={(freezes) => set({ ...p, freezes })} /> | |
| 646 | + | </div> | |
| 647 | + | <div> | |
| 648 | + | <p className="mb-2 text-sm text-fg">Exceptions: merging is open whatever else says</p> | |
| 649 | + | <PeriodList label="Exception" periods={p.exceptions} disabled={disabled} onChange={(exceptions) => set({ ...p, exceptions })} /> | |
| 650 | + | </div> | |
| 651 | + | </div> | |
| 652 | + | ); | |
| 653 | + | } | |
| 654 | + | case "agent_auto_merge": { | |
| 655 | + | const p = entry.parameters; | |
| 656 | + | return ( | |
| 657 | + | <div className="divide-y divide-line"> | |
| 658 | + | <Toggle title="Agents' ready changes may merge here by themselves" about="The repository's auto-merge setting must be on too." checked={p.allowed} disabled={disabled} onChange={(allowed) => set({ ...p, allowed })} /> | |
| 659 | + | <label className="flex items-center justify-between gap-4 py-1.5"> | |
| 660 | + | <span className="text-sm text-fg">Only at this confidence or higher</span> | |
| 661 | + | <Choice | |
| 662 | + | label="Lowest confidence that merges by itself" | |
| 663 | + | value={p.minimum_confidence ?? "any"} | |
| 664 | + | options={[{ value: "any", label: "Any" }, ...LEVELS]} | |
| 665 | + | disabled={disabled} | |
| 666 | + | onChange={(value) => set({ ...p, minimum_confidence: value === "any" ? null : value })} | |
| 667 | + | /> | |
| 668 | + | </label> | |
| 669 | + | </div> | |
| 670 | + | ); | |
| 671 | + | } | |
| 672 | + | default: | |
| 673 | + | return null; | |
| 674 | + | } | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | const APPLIES: { value: AppliesTo; label: string }[] = [ | |
| 678 | + | { value: "everyone", label: "Everyone" }, | |
| 679 | + | { value: "agents", label: "Agents' changes" }, | |
| 680 | + | { value: "people", label: "People's changes" }, | |
| 681 | + | ]; | |
| 682 | + | ||
| 683 | + | function RuleCard({ | |
| 684 | + | entry, | |
| 685 | + | onChange, | |
| 686 | + | onRemove, | |
| 687 | + | seen, | |
| 688 | + | disabled, | |
| 689 | + | }: { | |
| 690 | + | entry: RuleEntry; | |
| 691 | + | onChange: (entry: RuleEntry) => void; | |
| 692 | + | onRemove: () => void; | |
| 693 | + | seen: SeenCheck[]; | |
| 694 | + | disabled?: boolean; | |
| 695 | + | }) { | |
| 696 | + | const info = ruleInfo(entry.type); | |
| 697 | + | const agentsOnly = info?.group === "agents"; | |
| 698 | + | return ( | |
| 699 | + | <li className="rounded-xl border border-line bg-surface"> | |
| 700 | + | <div className="flex flex-wrap items-start justify-between gap-3 p-4"> | |
| 701 | + | <div className="min-w-0 grow basis-60"> | |
| 702 | + | <p className="flex items-center gap-2 text-sm font-medium"> | |
| 703 | + | {agentsOnly && <Bot size={14} className="text-accent" />} | |
| 704 | + | {info?.label ?? entry.type} | |
| 705 | + | </p> | |
| 706 | + | <p className="mt-0.5 text-sm text-muted">{info?.about}</p> | |
| 707 | + | </div> | |
| 708 | + | <div className="flex items-center gap-2"> | |
| 709 | + | <Choice label={`Whose changes ${info?.label ?? entry.type} holds for`} value={entry.applies_to} options={APPLIES} disabled={disabled} onChange={(applies_to) => onChange({ ...entry, applies_to })} /> | |
| 710 | + | {!disabled && ( | |
| 711 | + | <button type="button" aria-label={`Remove ${info?.label ?? entry.type}`} className="rounded-md p-1.5 text-faint hover:bg-bg hover:text-danger" onClick={onRemove}> | |
| 712 | + | <Trash2 size={15} /> | |
| 713 | + | </button> | |
| 714 | + | )} | |
| 715 | + | </div> | |
| 716 | + | </div> | |
| 717 | + | {Object.keys(entry.parameters).length > 0 && ( | |
| 718 | + | <div className="border-t border-line px-4 py-3"> | |
| 719 | + | <Parameters entry={entry} onChange={onChange} seen={seen} disabled={disabled} /> | |
| 720 | + | </div> | |
| 721 | + | )} | |
| 722 | + | </li> | |
| 723 | + | ); | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | // --- Bypass list -------------------------------------------------------------- | |
| 727 | + | ||
| 728 | + | const KINDS: { value: BypassActorKind; label: string; placeholder: string }[] = [ | |
| 729 | + | { value: "role", label: "Role", placeholder: "admin" }, | |
| 730 | + | { value: "team", label: "Team", placeholder: "workspace/team" }, | |
| 731 | + | { value: "user", label: "Person", placeholder: "username" }, | |
| 732 | + | { value: "token", label: "Token", placeholder: "token id, or workspace" }, | |
| 733 | + | { value: "g1t", label: "g1t", placeholder: "" }, | |
| 734 | + | ]; | |
| 735 | + | ||
| 736 | + | function BypassList({ actors, onChange, disabled }: { actors: BypassActor[]; onChange: (actors: BypassActor[]) => void; disabled?: boolean }) { | |
| 737 | + | const update = (index: number, change: Partial<BypassActor>) => onChange(actors.map((one, at) => (at === index ? { ...one, ...change } : one))); | |
| 738 | + | const hasG1t = actors.some((actor) => actor.kind === "g1t"); | |
| 739 | + | return ( | |
| 740 | + | <div className="space-y-2"> | |
| 741 | + | {actors.length === 0 && <p className="text-sm text-muted">Nobody: these rules hold for everyone, people and agents alike, g1t included.</p>} | |
| 742 | + | <ul className="space-y-2"> | |
| 743 | + | {actors.map((actor, index) => ( | |
| 744 | + | <li key={index} className="flex flex-wrap items-center gap-2 rounded-lg border border-line p-2"> | |
| 745 | + | <Choice label="Who" value={actor.kind} options={KINDS} disabled={disabled} onChange={(kind) => update(index, { kind, value: kind === "role" ? "admin" : "" })} /> | |
| 746 | + | {actor.kind === "role" ? ( | |
| 747 | + | <Choice | |
| 748 | + | label="Role" | |
| 749 | + | value={actor.value || "admin"} | |
| 750 | + | disabled={disabled} | |
| 751 | + | options={[ | |
| 752 | + | { value: "write", label: "Write and up" }, | |
| 753 | + | { value: "maintain", label: "Maintain and up" }, | |
| 754 | + | { value: "admin", label: "Admin" }, | |
| 755 | + | { value: "owner", label: "Workspace owners" }, | |
| 756 | + | ]} | |
| 757 | + | onChange={(value) => update(index, { value })} | |
| 758 | + | /> | |
| 759 | + | ) : actor.kind === "g1t" ? ( | |
| 760 | + | <span className="grow text-sm text-muted">g1t's agents and g1t itself (the merge queue, security updates)</span> | |
| 761 | + | ) : ( | |
| 762 | + | <Input aria-label="Who, by name" value={actor.value} disabled={disabled} placeholder={KINDS.find((kind) => kind.value === actor.kind)?.placeholder} className="w-48 grow" onChange={(event) => update(index, { value: event.target.value })} /> | |
| 763 | + | )} | |
| 764 | + | <Choice | |
| 765 | + | label="When" | |
| 766 | + | value={actor.mode} | |
| 767 | + | disabled={disabled} | |
| 768 | + | options={[ | |
| 769 | + | { value: "always", label: "Always" }, | |
| 770 | + | { value: "pull_requests", label: "Pull requests only" }, | |
| 771 | + | ]} | |
| 772 | + | onChange={(mode) => update(index, { mode })} | |
| 773 | + | /> | |
| 774 | + | {!disabled && ( | |
| 775 | + | <button type="button" aria-label={`Remove ${describeBypassActor(actor)}`} className="rounded p-1 text-faint hover:text-fg" onClick={() => onChange(actors.filter((_, at) => at !== index))}> | |
| 776 | + | <X size={14} /> | |
| 777 | + | </button> | |
| 778 | + | )} | |
| 779 | + | </li> | |
| 780 | + | ))} | |
| 781 | + | </ul> | |
| 782 | + | {hasG1t && ( | |
| 783 | + | <p className="flex items-start gap-2 text-xs text-warn"> | |
| 784 | + | <TriangleAlert size={13} className="mt-px shrink-0" /> g1t's agents bypass these rules. Agents obey rules exactly as people do unless they are listed here. | |
| 785 | + | </p> | |
| 786 | + | )} | |
| 787 | + | {!disabled && ( | |
| 788 | + | <Button type="button" variant="quiet" onClick={() => onChange([...actors, { kind: "role", value: "admin", mode: "pull_requests" }])}> | |
| 789 | + | <Plus size={14} /> Add a bypass | |
| 790 | + | </Button> | |
| 791 | + | )} | |
| 792 | + | </div> | |
| 793 | + | ); | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | // --- The form ----------------------------------------------------------------- | |
| 797 | + | ||
| 798 | + | function Block({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) { | |
| 799 | + | return ( | |
| 800 | + | <section className="grid gap-x-10 gap-y-3 border-t border-line pt-6 lg:grid-cols-[14rem_1fr]"> | |
| 801 | + | <div> | |
| 802 | + | <h2 className="font-medium">{title}</h2> | |
| 803 | + | <p className="mt-1 text-sm text-muted">{about}</p> | |
| 804 | + | </div> | |
| 805 | + | <div className="min-w-0 space-y-3">{children}</div> | |
| 806 | + | </section> | |
| 807 | + | ); | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | /** Downloads `ruleset` as a JSON file. */ | |
| 811 | + | function download(ruleset: RulesetSpec) { | |
| 812 | + | const blob = new Blob([JSON.stringify(exportRuleset(ruleset), null, 2) + "\n"], { type: "application/json" }); | |
| 813 | + | const url = URL.createObjectURL(blob); | |
| 814 | + | const link = document.createElement("a"); | |
| 815 | + | link.href = url; | |
| 816 | + | link.download = `${(ruleset.name || "ruleset").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "ruleset"}.json`; | |
| 817 | + | link.click(); | |
| 818 | + | URL.revokeObjectURL(url); | |
| 819 | + | } | |
| 820 | + | ||
| 821 | + | /** | |
| 822 | + | * Creating or changing a ruleset. The ruleset is posted whole, as JSON in | |
| 823 | + | * `ruleset`, with `intent` `save`; deleting posts `intent` `delete`. | |
| 824 | + | */ | |
| 825 | + | export function RulesetForm({ | |
| 826 | + | initial, | |
| 827 | + | level, | |
| 828 | + | existing, | |
| 829 | + | seen = [], | |
| 830 | + | editable, | |
| 831 | + | error, | |
| 832 | + | backHref, | |
| 833 | + | repositoryLabel, | |
| 834 | + | }: { | |
| 835 | + | initial: RulesetSpec; | |
| 836 | + | level: Level; | |
| 837 | + | /** The ruleset being changed; absent for a new one. */ | |
| 838 | + | existing?: Ruleset; | |
| 839 | + | /** Check names reported lately, to require. */ | |
| 840 | + | seen?: SeenCheck[]; | |
| 841 | + | editable: boolean; | |
| 842 | + | error?: string | null; | |
| 843 | + | backHref: string; | |
| 844 | + | /** For a repository's: its name, for the hint about the default branch. */ | |
| 845 | + | repositoryLabel?: string; | |
| 846 | + | }) { | |
| 847 | + | const [spec, setSpec] = useState<RulesetSpec>(initial); | |
| 848 | + | const [imported, setImported] = useState<string | null>(null); | |
| 849 | + | const file = useRef<HTMLInputElement>(null); | |
| 850 | + | const disabled = !editable; | |
| 851 | + | const update = (change: Partial<RulesetSpec>) => setSpec((current) => ({ ...current, ...change })); | |
| 852 | + | const addable = RULES.filter( | |
| 853 | + | (info) => info.targets.includes(spec.target) && (info.repeatable || !spec.rules.some((rule) => rule.type === info.type && rule.applies_to === "everyone")), | |
| 854 | + | ); | |
| 855 | + | const fromBranchProtection = existing?.source === "branch_protection"; | |
| 856 | + | return ( | |
| 857 | + | <> | |
| 858 | + | <Form method="post" className="space-y-6"> | |
| 859 | + | <input type="hidden" name="intent" value="save" /> | |
| 860 | + | <input type="hidden" name="ruleset" value={JSON.stringify(spec)} /> | |
| 861 | + | {fromBranchProtection && ( | |
| 862 | + | <p className="flex items-start gap-2 rounded-lg border border-info/30 bg-info/5 p-3 text-sm text-muted"> | |
| 863 | + | <ShieldCheck size={15} className="mt-0.5 shrink-0 text-info" /> | |
| 864 | + | Made from this repository's branch protection settings, holding exactly what they held. The pull request, status check and merge | |
| 865 | + | queue rules here are also what Settings → Branches and the settings API read and write. | |
| 866 | + | </p> | |
| 867 | + | )} | |
| 868 | + | <section className="grid gap-4 md:grid-cols-[1fr_auto]"> | |
| 869 | + | <label className="block"> | |
| 870 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Name</span> | |
| 871 | + | <Input name="name" value={spec.name} disabled={disabled} maxLength={100} placeholder="Protect main" required onChange={(event) => update({ name: event.target.value })} /> | |
| 872 | + | </label> | |
| 873 | + | <div> | |
| 874 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Targets</span> | |
| 875 | + | <div className="flex rounded-md border border-line p-0.5" role="radiogroup" aria-label="Targets"> | |
| 876 | + | {(["branch", "tag"] as const).map((target) => ( | |
| 877 | + | <button | |
| 878 | + | key={target} | |
| 879 | + | type="button" | |
| 880 | + | role="radio" | |
| 881 | + | aria-checked={spec.target === target} | |
| 882 | + | disabled={disabled} | |
| 883 | + | className={cn("flex items-center gap-1.5 rounded px-3 py-1.5 text-sm", spec.target === target ? "bg-surface text-fg" : "text-muted hover:text-fg")} | |
| 884 | + | onClick={() => | |
| 885 | + | update({ | |
| 886 | + | target, | |
| 887 | + | conditions: { ...spec.conditions, ref_name: { include: target === "tag" ? [] : [DEFAULT_BRANCH], exclude: [] } }, | |
| 888 | + | rules: spec.rules.filter((rule) => ruleInfo(rule.type)?.targets.includes(target)), | |
| 889 | + | }) | |
| 890 | + | } | |
| 891 | + | > | |
| 892 | + | {target === "branch" ? <GitBranch size={14} /> : <Tag size={14} />} | |
| 893 | + | {target === "branch" ? "Branches" : "Tags"} | |
| 894 | + | </button> | |
| 895 | + | ))} | |
| 896 | + | </div> | |
| 897 | + | </div> | |
| 898 | + | </section> | |
| 899 | + | ||
| 900 | + | <Block title="Enforcement" about="Try a ruleset in evaluate first: Insights shows what it would have refused."> | |
| 901 | + | <RadioGroup value={spec.enforcement} onValueChange={(value) => update({ enforcement: value as Enforcement })} disabled={disabled} className="grid gap-3 sm:grid-cols-3"> | |
| 902 | + | {(Object.keys(ENFORCEMENT) as Enforcement[]).map((enforcement) => ( | |
| 903 | + | <RadioOption key={enforcement} value={enforcement} label={ENFORCEMENT[enforcement].label} description={ENFORCEMENT[enforcement].about} /> | |
| 904 | + | ))} | |
| 905 | + | </RadioGroup> | |
| 906 | + | </Block> | |
| 907 | + | ||
| 908 | + | <Block | |
| 909 | + | title={spec.target === "branch" ? "Branches" : "Tags"} | |
| 910 | + | about={ | |
| 911 | + | <> | |
| 912 | + | Names it holds for: fnmatch patterns, <code>*</code> within a segment and <code>**</code> across them. | |
| 913 | + | {repositoryLabel ? ` ${repositoryLabel}'s default branch is matched by name too.` : ""} | |
| 914 | + | </> | |
| 915 | + | } | |
| 916 | + | > | |
| 917 | + | <div> | |
| 918 | + | <p className="mb-2 text-sm text-fg">Include</p> | |
| 919 | + | <PatternList | |
| 920 | + | label="Include" | |
| 921 | + | values={spec.conditions.ref_name.include} | |
| 922 | + | placeholder={spec.target === "branch" ? "release/*" : "v*"} | |
| 923 | + | quick={ | |
| 924 | + | spec.target === "branch" | |
| 925 | + | ? [ | |
| 926 | + | { value: DEFAULT_BRANCH, label: "Default branch" }, | |
| 927 | + | { value: ALL, label: "All branches" }, | |
| 928 | + | ] | |
| 929 | + | : [{ value: ALL, label: "All tags" }] | |
| 930 | + | } | |
| 931 | + | disabled={disabled} | |
| 932 | + | onChange={(include) => update({ conditions: { ...spec.conditions, ref_name: { ...spec.conditions.ref_name, include } } })} | |
| 933 | + | /> | |
| 934 | + | </div> | |
| 935 | + | <div> | |
| 936 | + | <p className="mb-2 text-sm text-fg">Exclude</p> | |
| 937 | + | <PatternList | |
| 938 | + | label="Exclude" | |
| 939 | + | values={spec.conditions.ref_name.exclude} | |
| 940 | + | placeholder={spec.target === "branch" ? "dependabot/**" : "v*-rc*"} | |
| 941 | + | disabled={disabled} | |
| 942 | + | onChange={(exclude) => update({ conditions: { ...spec.conditions, ref_name: { ...spec.conditions.ref_name, exclude } } })} | |
| 943 | + | /> | |
| 944 | + | </div> | |
| 945 | + | </Block> | |
| 946 | + | ||
| 947 | + | {level === "workspace" && spec.conditions.repository && ( | |
| 948 | + | <Block title="Repositories" about="Which of the workspace's repositories it holds in."> | |
| 949 | + | <div> | |
| 950 | + | <p className="mb-2 text-sm text-fg">Names to include</p> | |
| 951 | + | <PatternList | |
| 952 | + | label="Repositories to include" | |
| 953 | + | values={spec.conditions.repository.include} | |
| 954 | + | placeholder="api-*" | |
| 955 | + | quick={[{ value: ALL, label: "All repositories" }]} | |
| 956 | + | disabled={disabled} | |
| 957 | + | onChange={(include) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, include } } })} | |
| 958 | + | /> | |
| 959 | + | </div> | |
| 960 | + | <div> | |
| 961 | + | <p className="mb-2 text-sm text-fg">Names to exclude</p> | |
| 962 | + | <PatternList | |
| 963 | + | label="Repositories to exclude" | |
| 964 | + | values={spec.conditions.repository.exclude} | |
| 965 | + | placeholder="sandbox-*" | |
| 966 | + | disabled={disabled} | |
| 967 | + | onChange={(exclude) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, exclude } } })} | |
| 968 | + | /> | |
| 969 | + | </div> | |
| 970 | + | <label className="flex items-center justify-between gap-4"> | |
| 971 | + | <span className="text-sm text-fg">Visibility</span> | |
| 972 | + | <Choice | |
| 973 | + | label="Visibility" | |
| 974 | + | value={spec.conditions.repository.visibility} | |
| 975 | + | disabled={disabled} | |
| 976 | + | options={[ | |
| 977 | + | { value: "any", label: "Public and private" }, | |
| 978 | + | { value: "public", label: "Public only" }, | |
| 979 | + | { value: "private", label: "Private only" }, | |
| 980 | + | ]} | |
| 981 | + | onChange={(visibility) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, visibility } } })} | |
| 982 | + | /> | |
| 983 | + | </label> | |
| 984 | + | <div> | |
| 985 | + | <p className="text-sm text-fg">Topics</p> | |
| 986 | + | <p className="mb-2 text-xs text-muted">Only repositories carrying one of these. Empty: any.</p> | |
| 987 | + | <PatternList | |
| 988 | + | label="Topics" | |
| 989 | + | values={spec.conditions.repository.topics} | |
| 990 | + | placeholder="payments" | |
| 991 | + | mono={false} | |
| 992 | + | disabled={disabled} | |
| 993 | + | onChange={(topics) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, topics } } })} | |
| 994 | + | /> | |
| 995 | + | </div> | |
| 996 | + | </Block> | |
| 997 | + | )} | |
| 998 | + | ||
| 999 | + | <Block title="Bypass list" about="Who these rules do not hold for. Nobody bypasses unless listed: agents, g1t's included, obey rules as people do."> | |
| 1000 | + | <BypassList actors={spec.bypass_actors} disabled={disabled} onChange={(bypass_actors) => update({ bypass_actors })} /> | |
| 1001 | + | </Block> | |
| 1002 | + | ||
| 1003 | + | <Block title="Rules" about="Every rule holds at once, and with every other ruleset that targets the same branch: the most restrictive wins. Each can hold for everyone, only agents' changes, or only people's."> | |
| 1004 | + | {spec.rules.length === 0 && <p className="text-sm text-muted">No rules yet.</p>} | |
| 1005 | + | <ul className="space-y-3"> | |
| 1006 | + | {spec.rules.map((entry, index) => ( | |
| 1007 | + | <RuleCard | |
| 1008 | + | key={`${entry.type}-${index}`} | |
| 1009 | + | entry={entry} | |
| 1010 | + | seen={seen} | |
| 1011 | + | disabled={disabled} | |
| 1012 | + | onChange={(changed) => update({ rules: spec.rules.map((one, at) => (at === index ? changed : one)) })} | |
| 1013 | + | onRemove={() => update({ rules: spec.rules.filter((_, at) => at !== index) })} | |
| 1014 | + | /> | |
| 1015 | + | ))} | |
| 1016 | + | </ul> | |
| 1017 | + | {editable && ( | |
| 1018 | + | <DropdownMenu> | |
| 1019 | + | <DropdownMenuTrigger asChild> | |
| 1020 | + | <Button type="button" variant="quiet"> | |
| 1021 | + | <Plus size={14} /> Add a rule | |
| 1022 | + | </Button> | |
| 1023 | + | </DropdownMenuTrigger> | |
| 1024 | + | <DropdownMenuContent align="start" className="max-h-[60vh] w-80 overflow-y-auto"> | |
| 1025 | + | {RULE_GROUPS.map((group, groupIndex) => { | |
| 1026 | + | const rules = addable.filter((info) => info.group === group.id); | |
| 1027 | + | if (rules.length === 0) return null; | |
| 1028 | + | return ( | |
| 1029 | + | <div key={group.id}> | |
| 1030 | + | {groupIndex > 0 && <DropdownMenuSeparator />} | |
| 1031 | + | <DropdownMenuLabel>{group.label}</DropdownMenuLabel> | |
| 1032 | + | {rules.map((info) => ( | |
| 1033 | + | <DropdownMenuItem key={info.type} onSelect={() => update({ rules: [...spec.rules, newRule(info.type as RuleType)] })}> | |
| 1034 | + | <span className="min-w-0"> | |
| 1035 | + | <span className="block text-sm">{info.label}</span> | |
| 1036 | + | <span className="block text-xs text-muted">{info.about}</span> | |
| 1037 | + | </span> | |
| 1038 | + | </DropdownMenuItem> | |
| 1039 | + | ))} | |
| 1040 | + | </div> | |
| 1041 | + | ); | |
| 1042 | + | })} | |
| 1043 | + | </DropdownMenuContent> | |
| 1044 | + | </DropdownMenu> | |
| 1045 | + | )} | |
| 1046 | + | </Block> | |
| 1047 | + | ||
| 1048 | + | <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-3 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> | |
| 1049 | + | {editable && ( | |
| 1050 | + | <SubmitButton pending="Saving…" match={{ intent: "save" }}> | |
| 1051 | + | {existing ? "Save changes" : "Create ruleset"} | |
| 1052 | + | </SubmitButton> | |
| 1053 | + | )} | |
| 1054 | + | <Link to={backHref} className="text-sm text-muted hover:text-fg"> | |
| 1055 | + | {editable ? "Cancel" : "Back to rules"} | |
| 1056 | + | </Link> | |
| 1057 | + | <span className="grow" /> | |
| 1058 | + | <Button type="button" variant="quiet" onClick={() => download(spec)}> | |
| 1059 | + | <Download size={14} /> Export JSON | |
| 1060 | + | </Button> | |
| 1061 | + | {editable && ( | |
| 1062 | + | <> | |
| 1063 | + | <input | |
| 1064 | + | ref={file} | |
| 1065 | + | type="file" | |
| 1066 | + | accept="application/json,.json" | |
| 1067 | + | className="hidden" | |
| 1068 | + | onChange={async (event) => { | |
| 1069 | + | const chosen = event.target.files?.[0]; | |
| 1070 | + | event.target.value = ""; | |
| 1071 | + | if (!chosen) return; | |
| 1072 | + | try { | |
| 1073 | + | setSpec(importRuleset(await chosen.text(), level)); | |
| 1074 | + | setImported(`Imported ${chosen.name}. Check it, then save.`); | |
| 1075 | + | } catch (problem) { | |
| 1076 | + | setImported(problem instanceof Error ? problem.message : "That file could not be read."); | |
| 1077 | + | } | |
| 1078 | + | }} | |
| 1079 | + | /> | |
| 1080 | + | <Button type="button" variant="quiet" onClick={() => file.current?.click()}> | |
| 1081 | + | <FileUp size={14} /> Import JSON | |
| 1082 | + | </Button> | |
| 1083 | + | </> | |
| 1084 | + | )} | |
| 1085 | + | {imported && <p className="w-full text-sm text-muted">{imported}</p>} | |
| 1086 | + | <div className="w-full"> | |
| 1087 | + | <ErrorText>{error}</ErrorText> | |
| 1088 | + | </div> | |
| 1089 | + | </div> | |
| 1090 | + | </Form> | |
| 1091 | + | {editable && existing && ( | |
| 1092 | + | <Form method="post" className="mt-8 rounded-xl border border-danger/30 p-4"> | |
| 1093 | + | <input type="hidden" name="intent" value="delete" /> | |
| 1094 | + | <h2 className="font-medium">Delete this ruleset</h2> | |
| 1095 | + | <p className="mt-1 text-sm text-muted">Its rules stop holding at once. Its evaluations stay in Insights.</p> | |
| 1096 | + | <div className="mt-3"> | |
| 1097 | + | <SubmitButton variant="danger" pending="Deleting…" match={{ intent: "delete" }}> | |
| 1098 | + | <Trash2 size={14} /> Delete ruleset | |
| 1099 | + | </SubmitButton> | |
| 1100 | + | </div> | |
| 1101 | + | </Form> | |
| 1102 | + | )} | |
| 1103 | + | </> | |
| 1104 | + | ); | |
| 1105 | + | } | |
| 1106 | + | ||
| 1107 | + | // --- The list ----------------------------------------------------------------- | |
| 1108 | + | ||
| 1109 | + | function RulesetRow({ ruleset, href, inherited }: { ruleset: Ruleset; href: string; inherited?: boolean }) { | |
| 1110 | + | const agents = ruleset.rules.some((rule) => rule.applies_to === "agents" || ruleInfo(rule.type)?.group === "agents"); | |
| 1111 | + | return ( | |
| 1112 | + | <li> | |
| 1113 | + | <Link to={href} className="group flex flex-wrap items-start gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"> | |
| 1114 | + | <span className="mt-0.5 shrink-0 text-muted"> | |
| 1115 | + | {ruleset.enforcement === "disabled" ? <ShieldOff size={16} /> : <ShieldCheck size={16} className={ruleset.enforcement === "active" ? "text-accent" : "text-info"} />} | |
| 1116 | + | </span> | |
| 1117 | + | <span className="min-w-0 grow basis-48"> | |
| 1118 | + | <span className="flex flex-wrap items-center gap-2"> | |
| 1119 | + | <span className="font-medium group-hover:underline">{ruleset.name}</span> | |
| 1120 | + | <EnforcementBadge enforcement={ruleset.enforcement} /> | |
| 1121 | + | {inherited && <Badge>From the workspace</Badge>} | |
| 1122 | + | {ruleset.source === "branch_protection" && <Badge>Branch protection</Badge>} | |
| 1123 | + | {agents && ( | |
| 1124 | + | <Badge tone="merged"> | |
| 1125 | + | <Bot size={11} /> Agent rules | |
| 1126 | + | </Badge> | |
| 1127 | + | )} | |
| 1128 | + | </span> | |
| 1129 | + | <span className="mt-1 block text-sm text-muted"> | |
| 1130 | + | {ruleset.target === "tag" ? "Tags" : "Branches"}: {targetSummary(ruleset)} · {ruleset.rules.length} {ruleset.rules.length === 1 ? "rule" : "rules"} | |
| 1131 | + | {ruleset.bypass_actors.length > 0 && ` · ${ruleset.bypass_actors.length} may bypass`} | |
| 1132 | + | </span> | |
| 1133 | + | </span> | |
| 1134 | + | <span className="shrink-0 text-xs text-faint"> | |
| 1135 | + | Changed <TimeAgo at={ruleset.updated_at} /> by <span className="font-mono">{ruleset.updated_by}</span> | |
| 1136 | + | </span> | |
| 1137 | + | </Link> | |
| 1138 | + | </li> | |
| 1139 | + | ); | |
| 1140 | + | } | |
| 1141 | + | ||
| 1142 | + | export function RulesetList({ | |
| 1143 | + | rulesets, | |
| 1144 | + | hrefFor, | |
| 1145 | + | level, | |
| 1146 | + | empty, | |
| 1147 | + | }: { | |
| 1148 | + | rulesets: Ruleset[]; | |
| 1149 | + | hrefFor: (ruleset: Ruleset) => string; | |
| 1150 | + | level: Level; | |
| 1151 | + | empty: ReactNode; | |
| 1152 | + | }) { | |
| 1153 | + | if (rulesets.length === 0) { | |
| 1154 | + | return <div className="rounded-xl border border-dashed border-line p-8 text-center text-sm text-muted">{empty}</div>; | |
| 1155 | + | } | |
| 1156 | + | return ( | |
| 1157 | + | <ul className="space-y-2"> | |
| 1158 | + | {rulesets.map((ruleset) => ( | |
| 1159 | + | <RulesetRow key={ruleset.id} ruleset={ruleset} href={hrefFor(ruleset)} inherited={level === "repository" && ruleset.level === "workspace"} /> | |
| 1160 | + | ))} | |
| 1161 | + | </ul> | |
| 1162 | + | ); | |
| 1163 | + | } | |
| 1164 | + | ||
| 1165 | + | // --- Effective rules ---------------------------------------------------------- | |
| 1166 | + | ||
| 1167 | + | /** Every rule that holds for one branch, grouped by where it comes from. */ | |
| 1168 | + | export function EffectiveRulesView({ effective, hrefFor }: { effective: EffectiveRules; hrefFor: (id: string, level: Level) => string }) { | |
| 1169 | + | if (effective.rules.length === 0) { | |
| 1170 | + | return ( | |
| 1171 | + | <p className="rounded-xl border border-dashed border-line p-6 text-sm text-muted"> | |
| 1172 | + | No ruleset targets <span className="font-mono text-fg">{effective.name}</span>: anyone who may push can change it however they like. | |
| 1173 | + | </p> | |
| 1174 | + | ); | |
| 1175 | + | } | |
| 1176 | + | return ( | |
| 1177 | + | <div className="space-y-3"> | |
| 1178 | + | {effective.rulesets.map((ruleset) => { | |
| 1179 | + | const rules = effective.rules.filter((rule) => rule.ruleset_id === ruleset.id); | |
| 1180 | + | return ( | |
| 1181 | + | <div key={ruleset.id} className="rounded-xl border border-line"> | |
| 1182 | + | <div className="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2.5"> | |
| 1183 | + | <Link to={hrefFor(ruleset.id, ruleset.level)} className="font-medium hover:underline"> | |
| 1184 | + | {ruleset.name} | |
| 1185 | + | </Link> | |
| 1186 | + | <EnforcementBadge enforcement={ruleset.enforcement} /> | |
| 1187 | + | {ruleset.level === "workspace" && <Badge>Workspace</Badge>} | |
| 1188 | + | <span className="grow" /> | |
| 1189 | + | <span className="flex items-center gap-1 text-xs text-muted"> | |
| 1190 | + | <Users size={12} /> | |
| 1191 | + | {ruleset.bypass_actors.length === 0 ? "Nobody bypasses" : ruleset.bypass_actors.map(describeBypassActor).join(", ")} | |
| 1192 | + | </span> | |
| 1193 | + | </div> | |
| 1194 | + | <ul className="divide-y divide-line"> | |
| 1195 | + | {rules.map((rule, index) => ( | |
| 1196 | + | <li key={index} className="flex flex-wrap items-center gap-2 px-4 py-2 text-sm"> | |
| 1197 | + | <span className="grow">{ruleInfo(rule.type)?.label ?? rule.type}</span> | |
| 1198 | + | {rule.applies_to !== "everyone" && <Badge tone="merged">{describeAppliesTo(rule.applies_to)}</Badge>} | |
| 1199 | + | </li> | |
| 1200 | + | ))} | |
| 1201 | + | </ul> | |
| 1202 | + | </div> | |
| 1203 | + | ); | |
| 1204 | + | })} | |
| 1205 | + | </div> | |
| 1206 | + | ); | |
| 1207 | + | } | |
| 1208 | + | ||
| 1209 | + | // --- Insights ----------------------------------------------------------------- | |
| 1210 | + | ||
| 1211 | + | function Stat({ label, value, tone }: { label: string; value: number; tone?: "danger" | "info" | "warn" }) { | |
| 1212 | + | return ( | |
| 1213 | + | <div className="rounded-xl border border-line p-3"> | |
| 1214 | + | <p className="text-xs text-muted">{label}</p> | |
| 1215 | + | <p className={cn("mt-1 text-xl font-semibold tabular-nums", tone === "danger" && "text-danger", tone === "info" && "text-info", tone === "warn" && "text-warn")}>{value}</p> | |
| 1216 | + | </div> | |
| 1217 | + | ); | |
| 1218 | + | } | |
| 1219 | + | ||
| 1220 | + | const ACTION_LABEL: Record<string, string> = { | |
| 1221 | + | push: "Push", | |
| 1222 | + | merge: "Merge", | |
| 1223 | + | create_ref: "Create", | |
| 1224 | + | delete_ref: "Delete", | |
| 1225 | + | rename_ref: "Rename", | |
| 1226 | + | commit: "Commit", | |
| 1227 | + | }; | |
| 1228 | + | ||
| 1229 | + | function verdictBadge(evaluation: Evaluation) { | |
| 1230 | + | if (evaluation.verdict === "pass") return <Badge tone="accent">Passed</Badge>; | |
| 1231 | + | if (evaluation.verdict === "bypass") return <Badge tone="warn">Bypassed</Badge>; | |
| 1232 | + | if (evaluation.enforcement === "evaluate") return <Badge tone="info">Would block</Badge>; | |
| 1233 | + | return <Badge tone="danger">Blocked</Badge>; | |
| 1234 | + | } | |
| 1235 | + | ||
| 1236 | + | /** How the rules judged pushes and merges: the last 30 days, and each evaluation. */ | |
| 1237 | + | export function InsightsView({ page, showRepository, olderHref }: { page: EvaluationPage; showRepository?: boolean; olderHref?: string | null }) { | |
| 1238 | + | const insights = page.insights; | |
| 1239 | + | return ( | |
| 1240 | + | <div className="space-y-6"> | |
| 1241 | + | <div className="grid grid-cols-2 gap-2 sm:grid-cols-5"> | |
| 1242 | + | <Stat label={`Evaluations, ${insights.days} days`} value={insights.total} /> | |
| 1243 | + | <Stat label="Passed" value={insights.passed} /> | |
| 1244 | + | <Stat label="Blocked" value={insights.blocked} tone="danger" /> | |
| 1245 | + | <Stat label="Would block" value={insights.would_block} tone="info" /> | |
| 1246 | + | <Stat label="Bypassed" value={insights.bypassed} tone="warn" /> | |
| 1247 | + | </div> | |
| 1248 | + | {insights.by_rule.length > 0 && ( | |
| 1249 | + | <div> | |
| 1250 | + | <h3 className="mb-2 text-sm font-medium">Rules broken most</h3> | |
| 1251 | + | <ul className="space-y-1.5"> | |
| 1252 | + | {insights.by_rule.slice(0, 6).map((row) => { | |
| 1253 | + | const top = insights.by_rule[0]!.count || 1; | |
| 1254 | + | return ( | |
| 1255 | + | <li key={row.rule} className="flex items-center gap-3 text-sm"> | |
| 1256 | + | <span className="w-56 shrink-0 truncate">{ruleInfo(row.rule)?.label ?? row.rule}</span> | |
| 1257 | + | <span className="h-2 grow overflow-hidden rounded-full bg-surface"> | |
| 1258 | + | <span className="block h-full rounded-full bg-danger/70" style={{ width: `${Math.max(4, (row.count / top) * 100)}%` }} /> | |
| 1259 | + | </span> | |
| 1260 | + | <span className="w-10 shrink-0 text-right tabular-nums text-muted">{row.count}</span> | |
| 1261 | + | </li> | |
| 1262 | + | ); | |
| 1263 | + | })} | |
| 1264 | + | </ul> | |
| 1265 | + | </div> | |
| 1266 | + | )} | |
| 1267 | + | <div> | |
| 1268 | + | <h3 className="mb-2 text-sm font-medium">Recent evaluations</h3> | |
| 1269 | + | {page.evaluations.length === 0 ? ( | |
| 1270 | + | <p className="rounded-xl border border-dashed border-line p-6 text-sm text-muted">Nothing evaluated yet. Pushes and merges show here as the rules judge them.</p> | |
| 1271 | + | ) : ( | |
| 1272 | + | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 1273 | + | {page.evaluations.map((evaluation) => ( | |
| 1274 | + | <li key={evaluation.id} className="px-4 py-3"> | |
| 1275 | + | <div className="flex flex-wrap items-center gap-2 text-sm"> | |
| 1276 | + | {verdictBadge(evaluation)} | |
| 1277 | + | <span className="font-medium">{ACTION_LABEL[evaluation.action] ?? evaluation.action}</span> | |
| 1278 | + | <span className="font-mono text-[0.8125rem] text-muted">{evaluation.git_ref.replace(/^refs\/(heads|tags)\//, "")}</span> | |
| 1279 | + | {evaluation.number != null && <span className="text-muted">#{evaluation.number}</span>} | |
| 1280 | + | <span className="text-muted"> | |
| 1281 | + | by <span className="font-mono">{evaluation.actor}</span> | |
| 1282 | + | {evaluation.actor_kind !== "person" && <Bot size={12} className="ml-1 inline text-accent" />} | |
| 1283 | + | </span> | |
| 1284 | + | {showRepository && evaluation.repository && <span className="text-faint">{evaluation.repository}</span>} | |
| 1285 | + | <span className="grow" /> | |
| 1286 | + | <span className="text-xs text-faint"> | |
| 1287 | + | {evaluation.ruleset_name} · <TimeAgo at={evaluation.created_at} /> | |
| 1288 | + | </span> | |
| 1289 | + | </div> | |
| 1290 | + | {evaluation.violations.length > 0 && ( | |
| 1291 | + | <ul className="mt-1.5 space-y-0.5"> | |
| 1292 | + | {evaluation.violations.map((violation, index) => ( | |
| 1293 | + | <li key={index} className="text-sm text-muted"> | |
| 1294 | + | {violation.message} | |
| 1295 | + | </li> | |
| 1296 | + | ))} | |
| 1297 | + | </ul> | |
| 1298 | + | )} | |
| 1299 | + | </li> | |
| 1300 | + | ))} | |
| 1301 | + | </ul> | |
| 1302 | + | )} | |
| 1303 | + | {olderHref && ( | |
| 1304 | + | <Link to={olderHref} className="mt-3 inline-block text-sm text-muted hover:text-fg"> | |
| 1305 | + | Older evaluations → | |
| 1306 | + | </Link> | |
| 1307 | + | )} | |
| 1308 | + | </div> | |
| 1309 | + | </div> | |
| 1310 | + | ); | |
| 1311 | + | } | |
| 1312 | + | ||
| 1313 | + | // --- The merge box ------------------------------------------------------------ | |
| 1314 | + | ||
| 1315 | + | /** Rules a pull request does not meet, each with its ruleset and how to meet it. */ | |
| 1316 | + | export function ViolationList({ violations, tone = "danger" }: { violations: Violation[]; tone?: "danger" | "info" | "warn" }) { | |
| 1317 | + | const grouped = useMemo(() => { | |
| 1318 | + | const seen = new Set<string>(); | |
| 1319 | + | return violations.filter((violation) => { | |
| 1320 | + | const key = `${violation.rule}:${violation.message}`; | |
| 1321 | + | if (seen.has(key)) return false; | |
| 1322 | + | seen.add(key); | |
| 1323 | + | return true; | |
| 1324 | + | }); | |
| 1325 | + | }, [violations]); | |
| 1326 | + | return ( | |
| 1327 | + | <ul className="space-y-2"> | |
| 1328 | + | {grouped.map((violation, index) => ( | |
| 1329 | + | <li key={index} className="flex items-start gap-2 text-sm"> | |
| 1330 | + | <ShieldAlert size={14} className={cn("mt-0.5 shrink-0", tone === "danger" ? "text-danger" : tone === "info" ? "text-info" : "text-warn")} /> | |
| 1331 | + | <span className="min-w-0"> | |
| 1332 | + | <span className="text-fg">{violation.message}</span>{" "} | |
| 1333 | + | {violation.remedy && <span className="text-muted">{violation.remedy}</span>} | |
| 1334 | + | <span className="mt-0.5 block text-xs text-faint"> | |
| 1335 | + | {ruleInfo(violation.rule)?.label ?? violation.rule} · {violation.ruleset_name} | |
| 1336 | + | </span> | |
| 1337 | + | </span> | |
| 1338 | + | </li> | |
| 1339 | + | ))} | |
| 1340 | + | </ul> | |
| 1341 | + | ); | |
| 1342 | + | } |
| 1 | − | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 471 | 471 | * still open these. | |
| 472 | 472 | */ | |
| 473 | 473 | const SETTINGS_PAGE = | |
| 474 | − | /^\/([^/]+)\/-\/(settings|repositories|tokens|guardrails|secrets|runners|integrations|webhooks|billing|audit)(\/|$)/; | |
| 474 | + | /^\/([^/]+)\/-\/(settings|repositories|tokens|rules|guardrails|secrets|runners|integrations|webhooks|billing|audit)(\/|$)/; | |
| 475 | 475 | /** A project's settings pages, which the project's menu drills into. */ | |
| 476 | 476 | const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/; | |
| 477 | 477 | ||
| ⋯ | |||
| 821 | 821 | <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}> | |
| 822 | 822 | Access tokens | |
| 823 | 823 | </SidebarLink> | |
| 824 | + | <SidebarLink to={`/${slug}/-/rules`} icon={<Scale size={15} />}> | |
| 825 | + | Rules | |
| 826 | + | </SidebarLink> | |
| 824 | 827 | </div> | |
| 825 | 828 | <SidebarGroup title="Agents and runs" className="mt-3"> | |
| 826 | 829 | <SidebarLink to={`/${slug}/-/guardrails`} icon={<Gauge size={15} />}> | |
| ⋯ | |||
| 1042 | 1045 | Branches and merging | |
| 1043 | 1046 | </SidebarLink> | |
| 1044 | 1047 | )} | |
| 1048 | + | {shows("rules") && ( | |
| 1049 | + | <SidebarLink to={`${base}/settings/rules`} icon={<Scale size={15} />}> | |
| 1050 | + | Rules | |
| 1051 | + | </SidebarLink> | |
| 1052 | + | )} | |
| 1045 | 1053 | {shows("secrets") && ( | |
| 1046 | 1054 | <SidebarLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}> | |
| 1047 | 1055 | Secrets and variables | |
| ⋯ | |||
| 1334 | 1342 | webhooks: "Webhooks", | |
| 1335 | 1343 | domains: "Domains", | |
| 1336 | 1344 | guardrails: "Guardrails", | |
| 1345 | + | rules: "Rules", | |
| 1337 | 1346 | audit: "Audit log", | |
| 1338 | 1347 | tree: "Files", | |
| 1339 | 1348 | blob: "Files", | |
| 28 | 28 | repository: "manage_settings", | |
| 29 | 29 | agents: "manage_settings", | |
| 30 | 30 | branches: "manage_protection", | |
| 31 | + | rules: "manage_protection", | |
| 31 | 32 | guardrails: "manage_protection", | |
| 32 | 33 | webhooks: "manage_integrations", | |
| 33 | 34 | secrets: "manage_integrations", |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | ALL, | |
| 6 | + | DEFAULT_BRANCH, | |
| 7 | + | RULES, | |
| 8 | + | describeAppliesTo, | |
| 9 | + | describeBypassActor, | |
| 10 | + | exportRuleset, | |
| 11 | + | importRuleset, | |
| 12 | + | newRule, | |
| 13 | + | newRuleset, | |
| 14 | + | ruleInfo, | |
| 15 | + | targetSummary, | |
| 16 | + | } from "./rules.ts"; | |
| 17 | + | ||
| 18 | + | test("a new ruleset protects the default branch, and a workspace's holds everywhere", () => { | |
| 19 | + | const repo = newRuleset("repository"); | |
| 20 | + | assert.deepEqual(repo.conditions.ref_name.include, [DEFAULT_BRANCH]); | |
| 21 | + | assert.equal(repo.conditions.repository, undefined); | |
| 22 | + | assert.equal(repo.enforcement, "active"); | |
| 23 | + | const workspace = newRuleset("workspace"); | |
| 24 | + | assert.deepEqual(workspace.conditions.repository?.include, [ALL]); | |
| 25 | + | }); | |
| 26 | + | ||
| 27 | + | test("every rule type has a label, a group and its defaults", () => { | |
| 28 | + | const types = new Set(RULES.map((rule) => rule.type)); | |
| 29 | + | assert.equal(types.size, RULES.length, "each type once"); | |
| 30 | + | assert.equal(RULES.length, 26); | |
| 31 | + | for (const rule of RULES) { | |
| 32 | + | assert.ok(rule.label && rule.about && rule.targets.length > 0, rule.type); | |
| 33 | + | } | |
| 34 | + | const pull = newRule("pull_request", "agents"); | |
| 35 | + | assert.equal(pull.applies_to, "agents"); | |
| 36 | + | assert.equal(pull.type === "pull_request" && pull.parameters.required_approvals, 1); | |
| 37 | + | // A new rule's parameters are its own, not the defaults themselves. | |
| 38 | + | const a = newRule("file_path_restriction"); | |
| 39 | + | if (a.type === "file_path_restriction") a.parameters.restricted_file_paths.push("x"); | |
| 40 | + | const info = ruleInfo("file_path_restriction")!; | |
| 41 | + | assert.deepEqual((info.defaults as { restricted_file_paths: string[] }).restricted_file_paths, []); | |
| 42 | + | assert.equal(ruleInfo("no_such_rule"), undefined); | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | test("an export imports back as it was", () => { | |
| 46 | + | const ruleset = { | |
| 47 | + | ...newRuleset("repository"), | |
| 48 | + | name: "Protect main", | |
| 49 | + | rules: [newRule("deletion"), newRule("cost_cap", "agents")], | |
| 50 | + | }; | |
| 51 | + | const text = JSON.stringify({ ...exportRuleset(ruleset), id: "rs_1", created_by: "ada" }); | |
| 52 | + | const back = importRuleset(text, "repository"); | |
| 53 | + | assert.deepEqual(back, exportRuleset(ruleset)); | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | test("an import fills in what it leaves out and refuses what is not a ruleset", () => { | |
| 57 | + | const spec = importRuleset( | |
| 58 | + | JSON.stringify({ ruleset_name: "From the API", enforcement: "nonsense", rules: [{ type: "max_file_size", parameters: { max_file_size_mb: 5 } }] }), | |
| 59 | + | "workspace", | |
| 60 | + | ); | |
| 61 | + | assert.equal(spec.name, "From the API"); | |
| 62 | + | assert.equal(spec.enforcement, "active"); | |
| 63 | + | assert.equal(spec.rules[0]?.applies_to, "everyone"); | |
| 64 | + | assert.deepEqual(spec.conditions.repository?.include, [ALL]); | |
| 65 | + | assert.equal(spec.rules[0]?.type === "max_file_size" && spec.rules[0].parameters.max_file_size_mb, 5); | |
| 66 | + | assert.throws(() => importRuleset("{", "repository"), /not JSON/); | |
| 67 | + | assert.throws(() => importRuleset("[]", "repository"), /one JSON object/); | |
| 68 | + | assert.throws(() => importRuleset(JSON.stringify({ rules: [{ type: "teleport" }] }), "repository"), /teleport is not a rule type/); | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | test("who may bypass, and whose changes a rule holds for, read plainly", () => { | |
| 72 | + | assert.equal(describeBypassActor({ kind: "role", value: "maintain", mode: "always" }), "Maintain role and up"); | |
| 73 | + | assert.equal(describeBypassActor({ kind: "role", value: "owner", mode: "always" }), "Workspace owners"); | |
| 74 | + | assert.equal(describeBypassActor({ kind: "team", value: "acme/release", mode: "pull_requests" }), "@acme/release"); | |
| 75 | + | assert.equal(describeBypassActor({ kind: "g1t", value: "", mode: "always" }), "g1t"); | |
| 76 | + | assert.equal(describeBypassActor({ kind: "token", value: "workspace", mode: "always" }), "The workspace's tokens"); | |
| 77 | + | assert.equal(describeAppliesTo("agents"), "Agents' changes"); | |
| 78 | + | assert.equal(describeAppliesTo("everyone"), "Everyone"); | |
| 79 | + | }); | |
| 80 | + | ||
| 81 | + | test("what a ruleset targets, in a few words", () => { | |
| 82 | + | assert.equal(targetSummary({ conditions: { ref_name: { include: [DEFAULT_BRANCH, "release/*"], exclude: [] } } }), "Default branch, release/*"); | |
| 83 | + | assert.equal(targetSummary({ conditions: { ref_name: { include: [ALL], exclude: ["dependabot/**"] } } }), "All, except dependabot/**"); | |
| 84 | + | assert.equal(targetSummary({ conditions: { ref_name: { include: [], exclude: [] } } }), "Nothing yet"); | |
| 85 | + | }); |
| 1 | + | /** | |
| 2 | + | * Rulesets on the site: how each rule is shown and set up, a new ruleset | |
| 3 | + | * and rule with their defaults, export and import as JSON, and how people | |
| 4 | + | * read who may bypass a ruleset. The types are `@g1t/contracts`' rules.ts. | |
| 5 | + | */ | |
| 6 | + | import type { | |
| 7 | + | AppliesTo, | |
| 8 | + | BypassActor, | |
| 9 | + | Conditions, | |
| 10 | + | Enforcement, | |
| 11 | + | Level, | |
| 12 | + | PatternOperator, | |
| 13 | + | PatternParameters, | |
| 14 | + | Rule, | |
| 15 | + | RuleEntry, | |
| 16 | + | RuleType, | |
| 17 | + | RulesetSpec, | |
| 18 | + | Target, | |
| 19 | + | } from "@g1t/contracts"; | |
| 20 | + | ||
| 21 | + | /** The default branch, whatever it is called (contracts' DEFAULT_BRANCH). */ | |
| 22 | + | export const DEFAULT_BRANCH = "~DEFAULT_BRANCH"; | |
| 23 | + | /** Every branch, tag or repository (contracts' ALL). */ | |
| 24 | + | export const ALL = "~ALL"; | |
| 25 | + | ||
| 26 | + | /** How each rule is shown and set up on the site. */ | |
| 27 | + | export type RuleTypeInfo = { | |
| 28 | + | type: RuleType; | |
| 29 | + | label: string; | |
| 30 | + | /** What it does, in a sentence. */ | |
| 31 | + | about: string; | |
| 32 | + | group: "branches" | "pull_requests" | "commits" | "files" | "agents"; | |
| 33 | + | /** Whether it can target tags, branches or both. */ | |
| 34 | + | targets: Target[]; | |
| 35 | + | /** Parameters a new rule starts with. */ | |
| 36 | + | defaults: Rule["parameters"]; | |
| 37 | + | /** Rules that may appear more than once. */ | |
| 38 | + | repeatable?: boolean; | |
| 39 | + | }; | |
| 40 | + | ||
| 41 | + | const none = {} as Record<string, never>; | |
| 42 | + | const pattern = (operator: PatternOperator = "regex"): PatternParameters => ({ name: "", operator, pattern: "", negate: false }); | |
| 43 | + | ||
| 44 | + | export const RULES: RuleTypeInfo[] = [ | |
| 45 | + | { type: "creation", label: "Restrict creations", about: "Only bypass actors may create matching branches or tags.", group: "branches", targets: ["branch", "tag"], defaults: none }, | |
| 46 | + | { type: "update", label: "Restrict updates", about: "Only bypass actors may push to matching branches or tags, merges included.", group: "branches", targets: ["branch", "tag"], defaults: none }, | |
| 47 | + | { type: "deletion", label: "Restrict deletions", about: "Only bypass actors may delete matching branches or tags.", group: "branches", targets: ["branch", "tag"], defaults: none }, | |
| 48 | + | { type: "non_fast_forward", label: "Block force pushes", about: "Nobody rewrites history: a push must only add to it.", group: "branches", targets: ["branch", "tag"], defaults: none }, | |
| 49 | + | { type: "required_linear_history", label: "Require linear history", about: "No merge commits: rebase instead of merging the branch in.", group: "commits", targets: ["branch", "tag"], defaults: none }, | |
| 50 | + | { type: "required_signatures", label: "Require signed commits", about: "Every commit carries an SSH signature g1t verifies against its committer's account.", group: "commits", targets: ["branch", "tag"], defaults: none }, | |
| 51 | + | { | |
| 52 | + | type: "pull_request", | |
| 53 | + | label: "Require a pull request before merging", | |
| 54 | + | about: "Changes arrive only through pull requests, with the reviews set here.", | |
| 55 | + | group: "pull_requests", | |
| 56 | + | targets: ["branch"], | |
| 57 | + | defaults: { | |
| 58 | + | required_approvals: 1, | |
| 59 | + | count_agent_approvals: true, | |
| 60 | + | dismiss_stale_reviews_on_push: false, | |
| 61 | + | require_code_owner_review: false, | |
| 62 | + | require_last_push_approval: false, | |
| 63 | + | allowed_merge_methods: [], | |
| 64 | + | }, | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | type: "required_status_checks", | |
| 68 | + | label: "Require status checks to pass", | |
| 69 | + | about: "Checks that must pass on a pull request's head before it merges, for every file or only some paths.", | |
| 70 | + | group: "pull_requests", | |
| 71 | + | targets: ["branch"], | |
| 72 | + | defaults: { checks: [], strict: false, paths: [], allow_bypass_on_merge: false }, | |
| 73 | + | repeatable: true, | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | type: "merge_queue", | |
| 77 | + | label: "Require the merge queue", | |
| 78 | + | about: "Merging into the default branch joins the queue, which tests pull requests together before it moves.", | |
| 79 | + | group: "pull_requests", | |
| 80 | + | targets: ["branch"], | |
| 81 | + | defaults: { merge_method: "merge", max_entries_to_build: 4, min_entries_to_merge: 1, min_entries_wait_minutes: 0, check_response_timeout_minutes: 45 }, | |
| 82 | + | }, | |
| 83 | + | { type: "required_deployments", label: "Require deployments to succeed", about: "A pull request's head must have deployed to these environments.", group: "pull_requests", targets: ["branch"], defaults: { environments: ["preview"] } }, | |
| 84 | + | { type: "commit_message_pattern", label: "Commit message pattern", about: "Every commit message must match, or must not.", group: "commits", targets: ["branch", "tag"], defaults: pattern(), repeatable: true }, | |
| 85 | + | { type: "commit_author_email_pattern", label: "Commit author email pattern", about: "Every author address must match, or must not.", group: "commits", targets: ["branch", "tag"], defaults: pattern("ends_with"), repeatable: true }, | |
| 86 | + | { type: "committer_email_pattern", label: "Committer email pattern", about: "Every committer address must match, or must not.", group: "commits", targets: ["branch", "tag"], defaults: pattern("ends_with"), repeatable: true }, | |
| 87 | + | { type: "branch_name_pattern", label: "Branch name pattern", about: "New branches must be named to match, or not to.", group: "branches", targets: ["branch"], defaults: pattern(), repeatable: true }, | |
| 88 | + | { type: "tag_name_pattern", label: "Tag name pattern", about: "New tags must be named to match, or not to.", group: "branches", targets: ["tag"], defaults: pattern(), repeatable: true }, | |
| 89 | + | { type: "file_path_restriction", label: "Restrict file paths", about: "Changes to matching paths are refused.", group: "files", targets: ["branch", "tag"], defaults: { restricted_file_paths: [] }, repeatable: true }, | |
| 90 | + | { type: "file_extension_restriction", label: "Restrict file extensions", about: "Files with these extensions may not be added.", group: "files", targets: ["branch", "tag"], defaults: { restricted_file_extensions: [] } }, | |
| 91 | + | { type: "max_file_size", label: "Restrict file size", about: "No file larger than this.", group: "files", targets: ["branch", "tag"], defaults: { max_file_size_mb: 10 } }, | |
| 92 | + | { type: "max_file_path_length", label: "Restrict file path length", about: "No path longer than this.", group: "files", targets: ["branch", "tag"], defaults: { max_file_path_length: 255 } }, | |
| 93 | + | { type: "max_files_changed", label: "Restrict files changed", about: "A commit may change at most this many files.", group: "files", targets: ["branch", "tag"], defaults: { max_files: 100 } }, | |
| 94 | + | { type: "secret_scanning", label: "Block pushes that add secrets", about: "Every push is scanned; one too large to scan is refused instead of let through.", group: "files", targets: ["branch", "tag"], defaults: none }, | |
| 95 | + | { type: "confidence_threshold", label: "Confidence threshold", about: "An agent's change rated below this needs approvals from people.", group: "agents", targets: ["branch"], defaults: { minimum: "medium", required_approvals: 1 } }, | |
| 96 | + | { type: "cost_cap", label: "Cost cap", about: "Over this much agent spend, a pull request waits for a person before it merges or its agent continues.", group: "agents", targets: ["branch"], defaults: { max_usd: 10 } }, | |
| 97 | + | { type: "path_review", label: "Review for sensitive paths", about: "Changes to these paths need approvals from people, from a team if you name one.", group: "agents", targets: ["branch"], defaults: { paths: [], required_approvals: 1, team: null }, repeatable: true }, | |
| 98 | + | { type: "merge_window", label: "Merge window", about: "When pull requests may merge: weekly hours, freezes and exceptions.", group: "agents", targets: ["branch"], defaults: { time_zone: "UTC", windows: [], freezes: [], exceptions: [] } }, | |
| 99 | + | { type: "agent_auto_merge", label: "Agent auto-merge", about: "Whether an agent's ready change lands here without a person, and how sure g1t must be.", group: "agents", targets: ["branch"], defaults: { allowed: true, minimum_confidence: null } }, | |
| 100 | + | ]; | |
| 101 | + | ||
| 102 | + | export const RULE_GROUPS: { id: RuleTypeInfo["group"]; label: string }[] = [ | |
| 103 | + | { id: "branches", label: "Branches and tags" }, | |
| 104 | + | { id: "pull_requests", label: "Pull requests and checks" }, | |
| 105 | + | { id: "commits", label: "Commits" }, | |
| 106 | + | { id: "files", label: "Files" }, | |
| 107 | + | { id: "agents", label: "Agents, review and timing" }, | |
| 108 | + | ]; | |
| 109 | + | ||
| 110 | + | /** A rule type's information, or undefined for an unknown type. */ | |
| 111 | + | export function ruleInfo(type: string): RuleTypeInfo | undefined { | |
| 112 | + | return RULES.find((rule) => rule.type === type); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /** A new ruleset, as the form starts it. */ | |
| 116 | + | export function newRuleset(level: Level): RulesetSpec { | |
| 117 | + | return { | |
| 118 | + | name: "", | |
| 119 | + | enforcement: "active", | |
| 120 | + | target: "branch", | |
| 121 | + | conditions: { | |
| 122 | + | ref_name: { include: [DEFAULT_BRANCH], exclude: [] }, | |
| 123 | + | ...(level === "workspace" ? { repository: { include: [ALL], exclude: [], visibility: "any" as const, topics: [] } } : {}), | |
| 124 | + | }, | |
| 125 | + | bypass_actors: [], | |
| 126 | + | rules: [], | |
| 127 | + | }; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /** A new rule of a type, with its defaults. */ | |
| 131 | + | export function newRule(type: RuleType, appliesTo: AppliesTo = "everyone"): RuleEntry { | |
| 132 | + | const info = ruleInfo(type); | |
| 133 | + | return { type, parameters: structuredClone(info?.defaults ?? {}), applies_to: appliesTo } as RuleEntry; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /** What a ruleset exports as: its spec, nothing about where it was kept. */ | |
| 137 | + | export function exportRuleset(ruleset: RulesetSpec): RulesetSpec { | |
| 138 | + | const { name, enforcement, target, conditions, bypass_actors, rules } = ruleset; | |
| 139 | + | return { name, enforcement, target, conditions, bypass_actors, rules }; | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | const ENFORCEMENTS: Enforcement[] = ["active", "evaluate", "disabled"]; | |
| 143 | + | const APPLIES: AppliesTo[] = ["everyone", "agents", "people"]; | |
| 144 | + | ||
| 145 | + | /** | |
| 146 | + | * A ruleset from imported JSON: an exported one, or one as the API shows it | |
| 147 | + | * (`ruleset_name` read as its name). Parameters left out take their | |
| 148 | + | * defaults. Throws with what is wrong. | |
| 149 | + | */ | |
| 150 | + | export function importRuleset(text: string, level: Level): RulesetSpec { | |
| 151 | + | let raw: unknown; | |
| 152 | + | try { | |
| 153 | + | raw = JSON.parse(text); | |
| 154 | + | } catch { | |
| 155 | + | throw new Error("That is not JSON."); | |
| 156 | + | } | |
| 157 | + | if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("A ruleset is one JSON object."); | |
| 158 | + | const found = raw as Record<string, unknown>; | |
| 159 | + | const base = newRuleset(level); | |
| 160 | + | const name = typeof found.name === "string" ? found.name : typeof found.ruleset_name === "string" ? found.ruleset_name : ""; | |
| 161 | + | const rules = Array.isArray(found.rules) ? (found.rules as Record<string, unknown>[]) : []; | |
| 162 | + | for (const rule of rules) { | |
| 163 | + | if (typeof rule?.type !== "string" || !ruleInfo(rule.type)) throw new Error(`${String(rule?.type)} is not a rule type.`); | |
| 164 | + | } | |
| 165 | + | const conditions = (found.conditions as Partial<Conditions> | undefined) ?? base.conditions; | |
| 166 | + | return { | |
| 167 | + | name, | |
| 168 | + | enforcement: ENFORCEMENTS.includes(found.enforcement as Enforcement) ? (found.enforcement as Enforcement) : "active", | |
| 169 | + | target: found.target === "tag" ? "tag" : "branch", | |
| 170 | + | conditions: { | |
| 171 | + | ref_name: { include: conditions.ref_name?.include ?? [], exclude: conditions.ref_name?.exclude ?? [] }, | |
| 172 | + | ...(level === "workspace" ? { repository: conditions.repository ?? base.conditions.repository } : {}), | |
| 173 | + | }, | |
| 174 | + | bypass_actors: Array.isArray(found.bypass_actors) ? (found.bypass_actors as BypassActor[]) : [], | |
| 175 | + | rules: rules.map((rule) => { | |
| 176 | + | const info = ruleInfo(rule.type as string)!; | |
| 177 | + | return { | |
| 178 | + | type: info.type, | |
| 179 | + | parameters: { ...structuredClone(info.defaults), ...((rule.parameters as object) ?? {}) }, | |
| 180 | + | applies_to: APPLIES.includes(rule.applies_to as AppliesTo) ? (rule.applies_to as AppliesTo) : "everyone", | |
| 181 | + | } as RuleEntry; | |
| 182 | + | }), | |
| 183 | + | }; | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /** How people read who a bypass actor is. */ | |
| 187 | + | export function describeBypassActor(actor: BypassActor): string { | |
| 188 | + | switch (actor.kind) { | |
| 189 | + | case "g1t": | |
| 190 | + | return "g1t"; | |
| 191 | + | case "role": | |
| 192 | + | return actor.value === "owner" ? "Workspace owners" : `${actor.value[0]?.toUpperCase() ?? ""}${actor.value.slice(1)} role and up`; | |
| 193 | + | case "team": | |
| 194 | + | case "user": | |
| 195 | + | return `@${actor.value}`; | |
| 196 | + | case "token": | |
| 197 | + | return actor.value === "workspace" ? "The workspace's tokens" : `Token ${actor.value}`; | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /** How people read whose changes a rule holds for. */ | |
| 202 | + | export function describeAppliesTo(appliesTo: AppliesTo): string { | |
| 203 | + | return appliesTo === "agents" ? "Agents' changes" : appliesTo === "people" ? "People's changes" : "Everyone"; | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | function patternLabel(pattern: string): string { | |
| 207 | + | if (pattern === DEFAULT_BRANCH) return "Default branch"; | |
| 208 | + | if (pattern === ALL) return "All"; | |
| 209 | + | return pattern; | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /** A name pattern as people read it: `~DEFAULT_BRANCH` is "Default branch". */ | |
| 213 | + | export { patternLabel }; | |
| 214 | + | ||
| 215 | + | /** What a ruleset targets, in a few words: "Default branch, release/*". */ | |
| 216 | + | export function targetSummary(ruleset: Pick<RulesetSpec, "conditions">): string { | |
| 217 | + | const include = ruleset.conditions.ref_name.include.map(patternLabel); | |
| 218 | + | const exclude = ruleset.conditions.ref_name.exclude.map(patternLabel); | |
| 219 | + | const what = include.length === 0 ? "Nothing yet" : include.join(", "); | |
| 220 | + | return exclude.length ? `${what}, except ${exclude.join(", ")}` : what; | |
| 221 | + | } |
| 103 | 103 | route("-/audit", "routes/workspace/audit.tsx"), | |
| 104 | 104 | route("-/audit/export", "routes/workspace/audit-export.ts"), | |
| 105 | 105 | route("-/guardrails", "routes/workspace/guardrails.tsx"), | |
| 106 | + | route("-/rules", "routes/workspace/rules.tsx"), | |
| 107 | + | route("-/rules/:id", "routes/workspace/ruleset.tsx"), | |
| 106 | 108 | // What the workspace will have across its projects. | |
| 107 | 109 | route("-/soon/:feature", "routes/workspace/soon.tsx"), | |
| 108 | 110 | ]), | |
| ⋯ | |||
| 180 | 182 | route("settings/repository", "routes/repo/settings-repository.tsx"), | |
| 181 | 183 | route("settings/access", "routes/repo/settings-access.tsx"), | |
| 182 | 184 | route("settings/branches", "routes/repo/settings-branches.tsx"), | |
| 185 | + | route("settings/rules", "routes/repo/settings-rules.tsx"), | |
| 186 | + | route("settings/rules/:id", "routes/repo/settings-ruleset.tsx"), | |
| 183 | 187 | route("settings/webhooks", "routes/repo/webhooks.tsx"), | |
| 184 | 188 | route("settings/secrets", "routes/repo/secrets.tsx"), | |
| 185 | 189 | route("settings/runners", "routes/repo/settings-runners.tsx"), | |
| 9 | 9 | CircleSlash, | |
| 10 | 10 | GitMerge, | |
| 11 | 11 | Layers, | |
| 12 | + | Scale, | |
| 12 | 13 | GitPullRequestArrow, | |
| 13 | 14 | Hand, | |
| 14 | 15 | Loader, | |
| ⋯ | |||
| 24 | 25 | Users, | |
| 25 | 26 | Wrench, | |
| 26 | 27 | } from "lucide-react"; | |
| 27 | − | import { Suspense } from "react"; | |
| 28 | + | import { Suspense, useState } from "react"; | |
| 28 | 29 | import { Await, Form, Link, redirect } from "react-router"; | |
| 29 | 30 | ||
| 30 | 31 | import { | |
| ⋯ | |||
| 78 | 79 | verdicts, | |
| 79 | 80 | } from "../../components/work"; | |
| 80 | 81 | import { CatchUpProgress, ChecksSection, ConflictsSection, MergeabilityRow, runIdOf } from "../../components/merge-box"; | |
| 82 | + | import { ViolationList } from "../../components/rules"; | |
| 81 | 83 | import { PullCodeOwnersPanel, TeamReviewer } from "../../components/codeowners"; | |
| 82 | 84 | import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up"; | |
| 83 | 85 | import { notFound } from "../../lib/not-found.server"; | |
| ⋯ | |||
| 241 | 243 | members: members?.ok ? members.value.map((person) => person.username) : [], | |
| 242 | 244 | // `workspace/slug` and name of each team the viewer can see. | |
| 243 | 245 | teams: teamList?.ok ? teamList.value.map((team) => ({ ref: `${team.workspace}/${team.slug}`, name: team.name })) : [], | |
| 244 | − | requireUpToDate: protectedBase && settings.ok && settings.value.requireUpToDate, | |
| 245 | − | mergeQueue: protectedBase && settings.ok && settings.value.mergeQueue, | |
| 246 | − | requiredApprovals: protectedBase && settings.ok ? settings.value.requiredApprovals : 0, | |
| 247 | − | canIgnoreChecks: !settings.ok || settings.value.allowIgnoringChecks, | |
| 246 | + | // What the rules of the branch it merges into ask, as they stack; the | |
| 247 | + | // default branch's settings where the rules are not known. | |
| 248 | + | requireUpToDate: found.value.rules ? found.value.rules.strict : protectedBase && settings.ok && settings.value.requireUpToDate, | |
| 249 | + | mergeQueue: found.value.rules ? found.value.rules.merge_queue : protectedBase && settings.ok && settings.value.mergeQueue, | |
| 250 | + | requiredApprovals: found.value.rules | |
| 251 | + | ? found.value.rules.required_approvals | |
| 252 | + | : protectedBase && settings.ok | |
| 253 | + | ? settings.value.requiredApprovals | |
| 254 | + | : 0, | |
| 255 | + | canIgnoreChecks: found.value.rules ? found.value.rules.allow_bypass_on_merge : !settings.ok || settings.value.allowIgnoringChecks, | |
| 248 | 256 | noChecks, | |
| 249 | 257 | // Who may open the pull request that adds CI: anyone who can push. | |
| 250 | 258 | canAddCi: can.push, | |
| ⋯ | |||
| 392 | 400 | ? await work.mergePull(user, path, number, { | |
| 393 | 401 | keepIssueOpen: form.get("keepIssueOpen") === "on", | |
| 394 | 402 | ignoreChecks: form.get("ignoreChecks") === "on", | |
| 403 | + | bypassRules: form.get("bypassRules") === "on", | |
| 395 | 404 | }) | |
| 396 | 405 | : action === "unqueue" | |
| 397 | 406 | ? await work.removeFromQueue(user, path, number) | |
| ⋯ | |||
| 596 | 605 | members, | |
| 597 | 606 | teams, | |
| 598 | 607 | codeOwners, | |
| 608 | + | rules, | |
| 599 | 609 | tab, | |
| 600 | 610 | session, | |
| 601 | 611 | comparison, | |
| ⋯ | |||
| 680 | 690 | const probing = active && mergeable === "checking"; | |
| 681 | 691 | const conflicting = active && mergeable === "conflicting"; | |
| 682 | 692 | useRefreshWhile(working || checking || reviewPending || catchingUp || settling || moving || landing || probing); | |
| 693 | + | // The rules of its base it does not meet: checks show in their own | |
| 694 | + | // section, so the rest stop the button here. Someone a ruleset lets | |
| 695 | + | // bypass it may tick a box and merge past them. | |
| 696 | + | const [bypassing, setBypassing] = useState(false); | |
| 697 | + | const rulesUnmet = (rules?.unmet ?? []).filter((violation) => violation.rule !== "required_status_checks"); | |
| 698 | + | const rulesBypassable = rules?.bypassable ?? []; | |
| 683 | 699 | // Why the merge button cannot be pressed, if it cannot. | |
| 684 | 700 | const mergeBlocked = conflicting | |
| 685 | 701 | ? "Resolve the conflicts first." | |
| 686 | 702 | : probing | |
| 687 | 703 | ? "Waiting to find out whether it merges cleanly." | |
| 688 | 704 | : behind && requireUpToDate | |
| 689 | − | ? `This repository requires it to be up to date with ${defaultBranch} first.` | |
| 705 | + | ? `The rules for ${defaultBranch} require it to be up to date first.` | |
| 690 | 706 | : unchecked && !canIgnoreChecks | |
| 691 | 707 | ? `The checks ${defaultBranch} requires have to pass first.` | |
| 692 | − | : ownersMissing | |
| 693 | − | ? "Code owners have to approve first." | |
| 694 | − | : null; | |
| 708 | + | : rulesUnmet.length > 0 | |
| 709 | + | ? rulesUnmet[0]!.message | |
| 710 | + | : rulesBypassable.length > 0 && !bypassing | |
| 711 | + | ? `Rules for ${defaultBranch} are not met. You may bypass them.` | |
| 712 | + | : ownersMissing | |
| 713 | + | ? "Code owners have to approve first." | |
| 714 | + | : null; | |
| 695 | 715 | ||
| 696 | 716 | return ( | |
| 697 | 717 | // The changes get the whole width; people and settings are a tab away. | |
| ⋯ | |||
| 1180 | 1200 | </StatusRow> | |
| 1181 | 1201 | ) | |
| 1182 | 1202 | )} | |
| 1203 | + | {active && rules && (rulesUnmet.length > 0 || rulesBypassable.length > 0 || rules.evaluate.length > 0) && ( | |
| 1204 | + | <StatusRow | |
| 1205 | + | icon={ | |
| 1206 | + | <Scale | |
| 1207 | + | size={16} | |
| 1208 | + | className={rulesUnmet.length > 0 ? "text-danger" : rulesBypassable.length > 0 ? "text-warn" : "text-info"} | |
| 1209 | + | /> | |
| 1210 | + | } | |
| 1211 | + | title={ | |
| 1212 | + | rulesUnmet.length > 0 | |
| 1213 | + | ? `Rules for ${defaultBranch} are not met yet` | |
| 1214 | + | : rulesBypassable.length > 0 | |
| 1215 | + | ? `You may bypass the rules for ${defaultBranch}` | |
| 1216 | + | : "Rulesets being evaluated would refuse this merge" | |
| 1217 | + | } | |
| 1218 | + | > | |
| 1219 | + | <div className="mt-2 space-y-3"> | |
| 1220 | + | {rulesUnmet.length > 0 && <ViolationList violations={rulesUnmet} />} | |
| 1221 | + | {rulesBypassable.length > 0 && ( | |
| 1222 | + | <div> | |
| 1223 | + | <p className="mb-1.5 text-xs text-muted">Not met, but a ruleset lets you bypass it:</p> | |
| 1224 | + | <ViolationList violations={rulesBypassable} tone="warn" /> | |
| 1225 | + | </div> | |
| 1226 | + | )} | |
| 1227 | + | {rules.evaluate.length > 0 && ( | |
| 1228 | + | <div> | |
| 1229 | + | <p className="mb-1.5 text-xs text-muted">Rulesets in evaluate would refuse it for this; nothing is held up:</p> | |
| 1230 | + | <ViolationList violations={rules.evaluate} tone="info" /> | |
| 1231 | + | </div> | |
| 1232 | + | )} | |
| 1233 | + | {canProtect && ( | |
| 1234 | + | <Link | |
| 1235 | + | to={`${base}/settings/rules?branch=${encodeURIComponent(defaultBranch)}`} | |
| 1236 | + | className="inline-block text-xs text-muted underline underline-offset-2 hover:text-fg" | |
| 1237 | + | > | |
| 1238 | + | See every rule for {defaultBranch} | |
| 1239 | + | </Link> | |
| 1240 | + | )} | |
| 1241 | + | </div> | |
| 1242 | + | </StatusRow> | |
| 1243 | + | )} | |
| 1183 | 1244 | {stalled && !lifecycle && ( | |
| 1184 | 1245 | <StatusRow icon={<Hand size={16} className="text-warn" />} title="Needs you"> | |
| 1185 | 1246 | {stalled} | |
| ⋯ | |||
| 1212 | 1273 | labelClassName="text-xs text-muted" | |
| 1213 | 1274 | /> | |
| 1214 | 1275 | )} | |
| 1276 | + | {rulesBypassable.length > 0 && rulesUnmet.length === 0 && ( | |
| 1277 | + | <CheckboxOption | |
| 1278 | + | name="bypassRules" | |
| 1279 | + | checked={bypassing} | |
| 1280 | + | onCheckedChange={(checked) => setBypassing(checked === true)} | |
| 1281 | + | label={`Bypass the rules for ${defaultBranch}: merge although they are not met. It is recorded.`} | |
| 1282 | + | labelClassName="text-xs text-muted" | |
| 1283 | + | /> | |
| 1284 | + | )} | |
| 1215 | 1285 | <div className="flex flex-wrap items-center gap-3"> | |
| 1216 | 1286 | <SubmitButton | |
| 1217 | 1287 | variant="accent" | |
| 1 | − | import { ChevronRight, ShieldCheck } from "lucide-react"; | |
| 1 | + | import { ChevronRight, Scale, ShieldCheck } from "lucide-react"; | |
| 2 | 2 | import { Suspense } from "react"; | |
| 3 | 3 | import { Await, Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | + | import { ruleInfo } from "../../lib/rules"; | |
| 6 | + | ||
| 5 | 7 | import type { Route } from "./+types/settings-branches"; | |
| 6 | 8 | import { page } from "../../lib/meta"; | |
| 7 | 9 | import { AddCiPrompt } from "../../components/add-ci"; | |
| 8 | 10 | import { CodeownersReportPanel, CodeownersReportSkeleton } from "../../components/codeowners"; | |
| 9 | 11 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 10 | − | import { RequiredChecksPicker } from "../../components/required-checks"; | |
| 12 | + | import { EnforcementBadge } from "../../components/rules"; | |
| 11 | 13 | import { SettingChoice as Choice, SettingsSection as Section, SettingToggle as Toggle } from "../../components/settings-section"; | |
| 12 | 14 | import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 13 | 15 | import { actions, repos, work } from "../../lib/services.server"; | |
| ⋯ | |||
| 29 | 31 | work.seenChecks(path, viewer), | |
| 30 | 32 | actions.workflows(path, viewer), | |
| 31 | 33 | ]); | |
| 34 | + | const found = unwrap(repo); | |
| 35 | + | // What holds for the default branch: shown here, changed in Rules. | |
| 36 | + | const effective = await work.effectiveRules(path, found.defaultBranch, viewer).catch(() => null); | |
| 32 | 37 | // Streamed: the CODEOWNERS file is read and checked on its own time. | |
| 33 | 38 | const codeowners = work | |
| 34 | 39 | .codeownersErrors(path, viewer) | |
| 35 | − | .then((found) => (found.ok ? found.value : null)) | |
| 40 | + | .then((report) => (report.ok ? report.value : null)) | |
| 36 | 41 | .catch(() => null); | |
| 37 | 42 | return { | |
| 38 | 43 | codeowners, | |
| 39 | − | repo: unwrap(repo), | |
| 44 | + | repo: found, | |
| 40 | 45 | settings: unwrap(settings), | |
| 41 | − | // The names to choose required checks from: what reported lately. | |
| 42 | − | seen: seen.ok ? seen.value : [], | |
| 46 | + | effective: effective?.ok ? effective.value : null, | |
| 43 | 47 | // Nothing to require until something runs: the page offers to add CI. | |
| 44 | 48 | noChecks: workflows.ok && workflows.value.length === 0 && seen.ok && seen.value.length === 0, | |
| 45 | 49 | canPush: access.can.push, | |
| ⋯ | |||
| 59 | 63 | const form = await request.formData(); | |
| 60 | 64 | const path = { namespace: params.owner, name: params.repo }; | |
| 61 | 65 | const on = (name: string) => form.get(name) === "on"; | |
| 62 | − | // Protection belongs to the repository and how its pull requests are | |
| 63 | − | // handled to the work service; the page is one form over both. | |
| 64 | − | const repo = await repos.update(user, path, { protected: on("protected") }); | |
| 65 | − | if (!repo.ok) return { saved: false, error: repo.error.message }; | |
| 66 | + | // Branch protection is the repository's rulesets' (Settings → Rules): | |
| 67 | + | // what it holds is sent back as it is, so only how g1t's agents work | |
| 68 | + | // changes here. | |
| 69 | + | const current = await work.getSettings(path, user); | |
| 70 | + | if (!current.ok) return { saved: false, error: current.error.message }; | |
| 66 | 71 | const settings = await work.updateSettings(user, path, { | |
| 72 | + | ...current.value, | |
| 67 | 73 | autoMerge: on("autoMerge"), | |
| 68 | − | requiredChecks: form.getAll("requiredChecks").map(String), | |
| 69 | − | requireUpToDate: on("requireUpToDate"), | |
| 70 | − | requiredApprovals: count(form.get("requiredApprovals"), 0, 6), | |
| 71 | − | countAgentApprovals: on("countAgentApprovals"), | |
| 72 | − | allowIgnoringChecks: on("bypassChecks"), | |
| 73 | 74 | agentReview: on("agentReview"), | |
| 74 | 75 | maxRevisions: count(form.get("maxRevisions"), 0, 5), | |
| 75 | − | mergeQueue: on("mergeQueue"), | |
| 76 | 76 | holdLowConfidence: on("holdLowConfidence"), | |
| 77 | − | requireCodeOwnerReview: on("requireCodeOwnerReview"), | |
| 78 | 77 | }); | |
| 79 | 78 | return settings.ok ? { saved: true, error: null } : { saved: false, error: settings.error.message }; | |
| 80 | 79 | } | |
| 81 | 80 | ||
| 82 | 81 | export default function BranchSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 83 | − | const { repo, settings, seen, noChecks, canPush, codeowners } = loaderData; | |
| 82 | + | const { repo, settings, effective, noChecks, canPush, codeowners } = loaderData; | |
| 84 | 83 | const branch = repo.defaultBranch; | |
| 85 | 84 | const base = `/${repo.namespace}/${repo.name}`; | |
| 86 | 85 | const archived = Boolean(repo.archivedAt); | |
| 86 | + | const active = effective?.rules.filter((rule) => rule.enforcement === "active") ?? []; | |
| 87 | + | const labels = [...new Set(active.map((rule) => ruleInfo(rule.type)?.label ?? rule.type))]; | |
| 87 | 88 | return ( | |
| 88 | 89 | <> | |
| 89 | 90 | <RepoSettingsHeading base={base} /> | |
| ⋯ | |||
| 93 | 94 | <AddCiPrompt owner={repo.namespace} repo={repo.name} canAdd={canPush && !archived} /> | |
| 94 | 95 | </div> | |
| 95 | 96 | )} | |
| 96 | − | <Form method="post" className="max-w-4xl"> | |
| 97 | − | <fieldset disabled={archived} className="min-w-0 space-y-8"> | |
| 98 | − | <Section title="Branch protection" about={`Rules for ${branch}, the branch every pull request merges into. They hold for people and agents alike.`}> | |
| 99 | − | <Toggle name="protected" on={repo.protected} title={`Require a pull request to change ${branch}`}> | |
| 100 | − | Pushing to {branch} is refused, for members and agents alike, and git says why. Changes reach it only by | |
| 101 | − | merging a pull request. The first push to an empty repository is still allowed. | |
| 102 | − | </Toggle> | |
| 103 | − | <RequiredChecksPicker | |
| 104 | − | required={settings.requiredChecks ?? []} | |
| 105 | − | seen={seen} | |
| 106 | − | mergeQueue={settings.mergeQueue} | |
| 107 | − | disabled={archived} | |
| 108 | − | /> | |
| 109 | − | <p className="-mt-4 text-sm text-muted"> | |
| 110 | − | Code scanning results and dependency review gate merges the same way: require the <strong>Code scanning</strong> and{" "} | |
| 111 | − | <strong>Dependency review</strong> checks here once they have reported on a pull request. When each one fails is set in{" "} | |
| 112 | − | <Link to={`/${repo.namespace}/${repo.name}/security/settings`} className="underline underline-offset-2 hover:text-fg"> | |
| 113 | − | Security settings | |
| 114 | − | </Link> | |
| 115 | − | . | |
| 116 | − | </p> | |
| 117 | − | <Toggle name="bypassChecks" on={settings.allowIgnoringChecks} title="Allow bypassing required checks"> | |
| 118 | − | Someone who may merge can tick a box to merge although a required check failed or has not finished, and | |
| 119 | − | the pull request says who did. With this off, nobody can, and auto-merge never does. | |
| 120 | − | </Toggle> | |
| 121 | − | <Toggle | |
| 122 | − | name="requireUpToDate" | |
| 123 | − | on={settings.requireUpToDate} | |
| 124 | − | title="Require branches to be up to date before merging" | |
| 125 | − | > | |
| 126 | − | With this off, a pull request can be merged after {branch} has moved: g1t brings it up to date as part of | |
| 127 | − | merging, and asks you only if there is a conflict it cannot resolve. With it on, it has to catch up first | |
| 128 | − | and its required checks pass again on the result, so what lands is exactly what was checked. | |
| 129 | − | </Toggle> | |
| 130 | − | <Choice | |
| 131 | − | name="requiredApprovals" | |
| 132 | − | value={settings.requiredApprovals} | |
| 133 | − | title="Required approvals" | |
| 134 | − | options={[ | |
| 135 | − | [0, "None"], | |
| 136 | − | [1, "1"], | |
| 137 | − | [2, "2"], | |
| 138 | − | [3, "3"], | |
| 139 | − | ]} | |
| 140 | − | > | |
| 141 | − | How many reviewers must approve before a pull request can merge. A reviewer who has since asked for | |
| 142 | − | changes blocks it, and nobody approves their own. | |
| 143 | − | </Choice> | |
| 144 | − | <Toggle | |
| 145 | − | name="requireCodeOwnerReview" | |
| 146 | − | on={settings.requireCodeOwnerReview ?? false} | |
| 147 | − | title="Require review from code owners" | |
| 148 | − | > | |
| 149 | − | A pull request waits until the owners of every file it changes, as the CODEOWNERS file on {branch} names | |
| 150 | − | them, have approved. | |
| 151 | − | </Toggle> | |
| 152 | − | <Toggle name="countAgentApprovals" on={settings.countAgentApprovals} title="g1t's approval counts"> | |
| 153 | − | With this off, required approvals have to come from people, and an agent's review is advice. | |
| 154 | − | </Toggle> | |
| 155 | − | <Toggle name="mergeQueue" on={settings.mergeQueue} title="Merge through a queue"> | |
| 156 | − | Merging adds a pull request to the queue instead of changing {branch} at once. g1t builds it together with | |
| 157 | − | every pull request ahead of it, several combinations at a time, and runs the workflows that run on{" "} | |
| 158 | − | <code className="text-fg">merge_group</code> on each. {branch} only ever moves to a combination whose | |
| 159 | − | required checks passed. One that fails leaves the queue and goes back to its author. | |
| 160 | − | </Toggle> | |
| 161 | − | </Section> | |
| 162 | − | ||
| 163 | − | <Section | |
| 164 | − | id="codeowners" | |
| 165 | − | title="CODEOWNERS" | |
| 166 | − | about={`Who owns which files, read from ${branch}. Owners are asked to review changes to their files.`} | |
| 97 | + | <div className="max-w-4xl space-y-8"> | |
| 98 | + | <Section | |
| 99 | + | title="Branch protection" | |
| 100 | + | about={`What holds for ${branch} and every other branch is set by rulesets: for people and agents alike, and across the workspace.`} | |
| 101 | + | > | |
| 102 | + | <Link | |
| 103 | + | to={`${base}/settings/rules`} | |
| 104 | + | className="group flex items-start gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface" | |
| 167 | 105 | > | |
| 168 | − | <Suspense fallback={<CodeownersReportSkeleton />}> | |
| 169 | − | <Await resolve={codeowners}> | |
| 170 | − | {(report) => <CodeownersReportPanel report={report} base={base} branch={branch} />} | |
| 171 | − | </Await> | |
| 172 | − | </Suspense> | |
| 173 | − | </Section> | |
| 106 | + | <Scale size={16} className="mt-0.5 shrink-0 text-accent" /> | |
| 107 | + | <span className="min-w-0 grow"> | |
| 108 | + | <span className="block text-sm font-medium">Rules for {branch}</span> | |
| 109 | + | <span className="mt-1 block text-sm text-muted"> | |
| 110 | + | {labels.length > 0 ? labels.join(" · ") : `No active rules hold for ${branch}: anyone who may push can change it.`} | |
| 111 | + | </span> | |
| 112 | + | {effective && effective.rulesets.length > 0 && ( | |
| 113 | + | <span className="mt-2 flex flex-wrap gap-2"> | |
| 114 | + | {effective.rulesets.map((ruleset) => ( | |
| 115 | + | <span key={ruleset.id} className="inline-flex items-center gap-1.5 text-xs text-muted"> | |
| 116 | + | {ruleset.name} <EnforcementBadge enforcement={ruleset.enforcement} /> | |
| 117 | + | </span> | |
| 118 | + | ))} | |
| 119 | + | </span> | |
| 120 | + | )} | |
| 121 | + | </span> | |
| 122 | + | <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint transition-transform group-hover:translate-x-0.5" /> | |
| 123 | + | </Link> | |
| 124 | + | </Section> | |
| 174 | 125 | ||
| 175 | − | <Section | |
| 176 | − | title="g1t" | |
| 177 | − | about="What happens to a pull request g1t makes, from the moment it is ready. Its checks are the same workflows, and the rules above hold." | |
| 178 | − | > | |
| 179 | − | <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent"> | |
| 180 | − | A different agent reads each change and posts comments on lines, a summary and a verdict. If it asks for | |
| 181 | − | changes, the author is sent back to make them. With this off, review is left to people. | |
| 182 | − | </Toggle> | |
| 183 | − | <Choice | |
| 184 | − | name="maxRevisions" | |
| 185 | − | value={settings.maxRevisions} | |
| 186 | − | title="Revisions before asking you" | |
| 187 | − | options={[ | |
| 188 | − | [0, "None"], | |
| 189 | − | [1, "1"], | |
| 190 | − | [2, "2"], | |
| 191 | − | [3, "3"], | |
| 192 | − | [5, "5"], | |
| 193 | − | ]} | |
| 126 | + | <Section | |
| 127 | + | id="codeowners" | |
| 128 | + | title="CODEOWNERS" | |
| 129 | + | about={`Who owns which files, read from ${branch}. Owners are asked to review changes to their files.`} | |
| 130 | + | > | |
| 131 | + | <Suspense fallback={<CodeownersReportSkeleton />}> | |
| 132 | + | <Await resolve={codeowners}> | |
| 133 | + | {(report) => <CodeownersReportPanel report={report} base={base} branch={branch} />} | |
| 134 | + | </Await> | |
| 135 | + | </Suspense> | |
| 136 | + | </Section> | |
| 137 | + | ||
| 138 | + | <Form method="post"> | |
| 139 | + | <fieldset disabled={archived} className="min-w-0 space-y-8"> | |
| 140 | + | <Section | |
| 141 | + | title="g1t" | |
| 142 | + | about="What happens to a pull request g1t makes, from the moment it is ready. Its checks are the same workflows, and the branch's rules hold." | |
| 194 | 143 | > | |
| 195 | − | How many times an agent is sent back to fix a failed check, with what its jobs printed, or to address a | |
| 196 | − | review, before g1t stops and the pull request says it needs you. After that, only a required check that | |
| 197 | − | still fails holds it. | |
| 198 | − | </Choice> | |
| 199 | − | <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready"> | |
| 200 | − | A pull request g1t made lands without anyone pressing merge once every rule above is met, its required | |
| 201 | − | checks included. With this off, it waits for a member. Pull requests from people and from other agents | |
| 202 | − | always wait. | |
| 203 | − | </Toggle> | |
| 204 | − | <Toggle | |
| 205 | − | name="holdLowConfidence" | |
| 206 | − | on={settings.holdLowConfidence} | |
| 207 | − | title="Ask a person before merging low-confidence changes" | |
| 208 | − | > | |
| 209 | − | g1t rates how sure it is of each change an agent finishes, from its required checks, revisions, review, | |
| 210 | − | tests, size and guardrails. One it rates low waits for a member to approve it, instead of merging by itself | |
| 211 | − | or joining the queue, and shows on Mission control as needing you. | |
| 212 | − | </Toggle> | |
| 213 | − | <Link | |
| 214 | − | to={`${base}/settings/guardrails`} | |
| 215 | − | className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface" | |
| 216 | − | > | |
| 217 | − | <ShieldCheck size={16} className="shrink-0 text-accent" /> | |
| 218 | − | <span className="min-w-0 grow"> | |
| 219 | − | <span className="block text-sm font-medium">Guardrails</span> | |
| 220 | − | <span className="mt-0.5 block text-sm text-muted"> | |
| 221 | − | What agents may reach, run and spend while they work on this repository. | |
| 144 | + | <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent"> | |
| 145 | + | A different agent reads each change and posts comments on lines, a summary and a verdict. If it asks for | |
| 146 | + | changes, the author is sent back to make them. With this off, review is left to people. | |
| 147 | + | </Toggle> | |
| 148 | + | <Choice | |
| 149 | + | name="maxRevisions" | |
| 150 | + | value={settings.maxRevisions} | |
| 151 | + | title="Revisions before asking you" | |
| 152 | + | options={[ | |
| 153 | + | [0, "None"], | |
| 154 | + | [1, "1"], | |
| 155 | + | [2, "2"], | |
| 156 | + | [3, "3"], | |
| 157 | + | [5, "5"], | |
| 158 | + | ]} | |
| 159 | + | > | |
| 160 | + | How many times an agent is sent back to fix a failed check, with what its jobs printed, or to address a | |
| 161 | + | review, before g1t stops and the pull request says it needs you. After that, only a required check that | |
| 162 | + | still fails holds it. | |
| 163 | + | </Choice> | |
| 164 | + | <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready"> | |
| 165 | + | A pull request g1t made lands without anyone pressing merge once every rule of its branch is met, its | |
| 166 | + | required checks included. A ruleset can turn this off for some branches, or ask for a confidence first | |
| 167 | + | (Agent auto-merge). With this off, it waits for a member. Pull requests from people and from other agents | |
| 168 | + | always wait. | |
| 169 | + | </Toggle> | |
| 170 | + | <Toggle | |
| 171 | + | name="holdLowConfidence" | |
| 172 | + | on={settings.holdLowConfidence} | |
| 173 | + | title="Ask a person before merging low-confidence changes" | |
| 174 | + | > | |
| 175 | + | g1t rates how sure it is of each change an agent finishes, from its required checks, revisions, review, | |
| 176 | + | tests, size and guardrails. One it rates low waits for a member to approve it, instead of merging by itself | |
| 177 | + | or joining the queue, and shows on Mission control as needing you. A Confidence threshold rule asks for | |
| 178 | + | more, branch by branch. | |
| 179 | + | </Toggle> | |
| 180 | + | <Link | |
| 181 | + | to={`${base}/settings/guardrails`} | |
| 182 | + | className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface" | |
| 183 | + | > | |
| 184 | + | <ShieldCheck size={16} className="shrink-0 text-accent" /> | |
| 185 | + | <span className="min-w-0 grow"> | |
| 186 | + | <span className="block text-sm font-medium">Guardrails</span> | |
| 187 | + | <span className="mt-0.5 block text-sm text-muted"> | |
| 188 | + | What agents may reach, run and spend while they work on this repository. | |
| 189 | + | </span> | |
| 222 | 190 | </span> | |
| 223 | − | </span> | |
| 224 | − | <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" /> | |
| 225 | − | </Link> | |
| 226 | − | </Section> | |
| 191 | + | <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" /> | |
| 192 | + | </Link> | |
| 193 | + | </Section> | |
| 227 | 194 | ||
| 228 | − | <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"> | |
| 229 | − | <SubmitButton pending="Saving…" disabled={archived}> | |
| 230 | − | Save settings | |
| 231 | − | </SubmitButton> | |
| 232 | − | {actionData?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 233 | − | <ErrorText>{actionData?.error}</ErrorText> | |
| 234 | − | {settings.updatedBy && settings.updatedAt && !actionData && ( | |
| 235 | − | <span className="text-xs text-faint"> | |
| 236 | − | Merge rules last changed by <span className="font-mono">{settings.updatedBy}</span>{" "} | |
| 237 | − | <TimeAgo at={settings.updatedAt} /> | |
| 238 | − | </span> | |
| 239 | − | )} | |
| 240 | − | </div> | |
| 241 | − | </fieldset> | |
| 242 | − | </Form> | |
| 195 | + | <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"> | |
| 196 | + | <SubmitButton pending="Saving…" disabled={archived}> | |
| 197 | + | Save settings | |
| 198 | + | </SubmitButton> | |
| 199 | + | {actionData?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 200 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 201 | + | {settings.updatedBy && settings.updatedAt && !actionData && ( | |
| 202 | + | <span className="text-xs text-faint"> | |
| 203 | + | Last changed by <span className="font-mono">{settings.updatedBy}</span> <TimeAgo at={settings.updatedAt} /> | |
| 204 | + | </span> | |
| 205 | + | )} | |
| 206 | + | </div> | |
| 207 | + | </fieldset> | |
| 208 | + | </Form> | |
| 209 | + | </div> | |
| 243 | 210 | </> | |
| 244 | 211 | ); | |
| 245 | 212 | } | |
| 1 | + | import { ChartColumn, Plus, Search, ShieldCheck } from "lucide-react"; | |
| 2 | + | import { Form, Link, useSearchParams } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/settings-rules"; | |
| 5 | + | import { page } from "../../lib/meta"; | |
| 6 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 7 | + | import { EffectiveRulesView, InsightsView, RulesetList } from "../../components/rules"; | |
| 8 | + | import { ButtonLink } from "../../components/ui"; | |
| 9 | + | import { Input } from "../../components/ui/input"; | |
| 10 | + | import { work } from "../../lib/services.server"; | |
| 11 | + | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 12 | + | import { requireInsider } from "../../lib/access.server"; | |
| 13 | + | ||
| 14 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 15 | + | return page(args, { title: `Rules · ${params.owner}/${params.repo} · g1t` }); | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 19 | + | const viewer = getViewer(context); | |
| 20 | + | // Maintain and up, as for branch protection. | |
| 21 | + | const { repo, access } = await requireInsider(context, params, "manage_protection"); | |
| 22 | + | const url = new URL(request.url); | |
| 23 | + | const tab = url.searchParams.get("tab") === "insights" ? "insights" : "rulesets"; | |
| 24 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 25 | + | const tagged = url.searchParams.get("tag"); | |
| 26 | + | const branch = (tagged ?? url.searchParams.get("branch") ?? repo.defaultBranch).trim() || repo.defaultBranch; | |
| 27 | + | const [rulesets, effective, evaluations] = await Promise.all([ | |
| 28 | + | work.listRulesets({ repo: path }, viewer, true), | |
| 29 | + | tab === "rulesets" ? work.effectiveRules(path, branch, viewer, tagged ? "tag" : "branch") : Promise.resolve(null), | |
| 30 | + | tab === "insights" | |
| 31 | + | ? work.ruleEvaluations({ repo: path }, viewer, { | |
| 32 | + | before: url.searchParams.get("before") ?? undefined, | |
| 33 | + | problems_only: url.searchParams.get("problems") === "1", | |
| 34 | + | }) | |
| 35 | + | : Promise.resolve(null), | |
| 36 | + | ]); | |
| 37 | + | return { | |
| 38 | + | tab, | |
| 39 | + | branch, | |
| 40 | + | target: tagged ? ("tag" as const) : ("branch" as const), | |
| 41 | + | defaultBranch: repo.defaultBranch, | |
| 42 | + | rulesets: unwrap(rulesets), | |
| 43 | + | effective: effective?.ok ? effective.value : null, | |
| 44 | + | evaluations: evaluations?.ok ? evaluations.value : null, | |
| 45 | + | editable: access.can.manage_protection && !repo.archivedAt, | |
| 46 | + | }; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | export default function RepoRules({ loaderData, params }: Route.ComponentProps) { | |
| 50 | + | const { tab, branch, target, defaultBranch, rulesets, effective, evaluations, editable } = loaderData; | |
| 51 | + | const base = `/${params.owner}/${params.repo}`; | |
| 52 | + | const [search] = useSearchParams(); | |
| 53 | + | const own = rulesets.filter((ruleset) => ruleset.level === "repository"); | |
| 54 | + | const inherited = rulesets.filter((ruleset) => ruleset.level === "workspace"); | |
| 55 | + | const hrefFor = (id: string, level: "repository" | "workspace") => (level === "workspace" ? `/${params.owner}/-/rules/${id}` : `${base}/settings/rules/${id}`); | |
| 56 | + | const tabLink = (name: "rulesets" | "insights") => { | |
| 57 | + | const next = new URLSearchParams(search); | |
| 58 | + | next.delete("before"); | |
| 59 | + | if (name === "rulesets") next.delete("tab"); | |
| 60 | + | else next.set("tab", name); | |
| 61 | + | const query = next.toString(); | |
| 62 | + | return `${base}/settings/rules${query ? `?${query}` : ""}`; | |
| 63 | + | }; | |
| 64 | + | return ( | |
| 65 | + | <div className="max-w-4xl"> | |
| 66 | + | <RepoSettingsHeading base={base} /> | |
| 67 | + | <nav className="mb-6 flex gap-1 border-b border-line" aria-label="Rules"> | |
| 68 | + | {(["rulesets", "insights"] as const).map((name) => ( | |
| 69 | + | <Link | |
| 70 | + | key={name} | |
| 71 | + | to={tabLink(name)} | |
| 72 | + | aria-current={tab === name ? "page" : undefined} | |
| 73 | + | className={`-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm ${tab === name ? "border-accent text-fg" : "border-transparent text-muted hover:text-fg"}`} | |
| 74 | + | > | |
| 75 | + | {name === "rulesets" ? <ShieldCheck size={14} /> : <ChartColumn size={14} />} | |
| 76 | + | {name === "rulesets" ? "Rulesets" : "Insights"} | |
| 77 | + | </Link> | |
| 78 | + | ))} | |
| 79 | + | </nav> | |
| 80 | + | ||
| 81 | + | {tab === "rulesets" ? ( | |
| 82 | + | <div className="space-y-10"> | |
| 83 | + | <section> | |
| 84 | + | <div className="mb-3 flex flex-wrap items-center justify-between gap-3"> | |
| 85 | + | <div> | |
| 86 | + | <h2 className="font-medium">This repository's rulesets</h2> | |
| 87 | + | <p className="mt-1 text-sm text-muted">They stack with the workspace's: every rule of each that targets a branch holds there.</p> | |
| 88 | + | </div> | |
| 89 | + | {editable && ( | |
| 90 | + | <ButtonLink to={`${base}/settings/rules/new`}> | |
| 91 | + | <Plus size={15} /> New ruleset | |
| 92 | + | </ButtonLink> | |
| 93 | + | )} | |
| 94 | + | </div> | |
| 95 | + | <RulesetList | |
| 96 | + | rulesets={own} | |
| 97 | + | level="repository" | |
| 98 | + | hrefFor={(ruleset) => hrefFor(ruleset.id, ruleset.level)} | |
| 99 | + | empty={ | |
| 100 | + | <> | |
| 101 | + | No rulesets yet. Anyone who may push can push to any branch, and pull requests merge without approvals or checks. | |
| 102 | + | {editable && ( | |
| 103 | + | <> | |
| 104 | + | {" "} | |
| 105 | + | <Link to={`${base}/settings/rules/new`} className="text-fg underline underline-offset-2"> | |
| 106 | + | Create one | |
| 107 | + | </Link>{" "} | |
| 108 | + | to protect {defaultBranch}. | |
| 109 | + | </> | |
| 110 | + | )} | |
| 111 | + | </> | |
| 112 | + | } | |
| 113 | + | /> | |
| 114 | + | </section> | |
| 115 | + | {inherited.length > 0 && ( | |
| 116 | + | <section> | |
| 117 | + | <h2 className="font-medium">From the workspace</h2> | |
| 118 | + | <p className="mt-1 mb-3 text-sm text-muted">Set for every repository of {params.owner} they select. Owners change them in the workspace's settings.</p> | |
| 119 | + | <RulesetList rulesets={inherited} level="repository" hrefFor={(ruleset) => hrefFor(ruleset.id, ruleset.level)} empty={null} /> | |
| 120 | + | </section> | |
| 121 | + | )} | |
| 122 | + | <section> | |
| 123 | + | <h2 className="font-medium">What holds for a branch</h2> | |
| 124 | + | <p className="mt-1 mb-3 text-sm text-muted">Every rule of every ruleset that targets it, active ones first.</p> | |
| 125 | + | <Form method="get" className="mb-4 flex flex-wrap items-center gap-2"> | |
| 126 | + | <div className="relative w-72 max-w-full"> | |
| 127 | + | <Search size={14} className="absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 128 | + | <Input name={target === "tag" ? "tag" : "branch"} defaultValue={branch} aria-label="Branch" placeholder={defaultBranch} className="pl-8 font-mono text-[0.8125rem]" /> | |
| 129 | + | </div> | |
| 130 | + | <button type="submit" className="rounded-md border border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg"> | |
| 131 | + | Show rules | |
| 132 | + | </button> | |
| 133 | + | </Form> | |
| 134 | + | {effective ? ( | |
| 135 | + | <EffectiveRulesView effective={effective} hrefFor={hrefFor} /> | |
| 136 | + | ) : ( | |
| 137 | + | <p className="text-sm text-muted">The rules for that name could not be read.</p> | |
| 138 | + | )} | |
| 139 | + | </section> | |
| 140 | + | </div> | |
| 141 | + | ) : evaluations ? ( | |
| 142 | + | <InsightsView page={evaluations} olderHref={evaluations.next ? `${base}/settings/rules?tab=insights&before=${encodeURIComponent(evaluations.next)}` : null} /> | |
| 143 | + | ) : ( | |
| 144 | + | <p className="text-sm text-muted">Insights could not be read just now.</p> | |
| 145 | + | )} | |
| 146 | + | </div> | |
| 147 | + | ); | |
| 148 | + | } |
| 1 | + | import { ArrowLeft } from "lucide-react"; | |
| 2 | + | import { Link, data, redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { RulesetSpec } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/settings-ruleset"; | |
| 7 | + | import { page } from "../../lib/meta"; | |
| 8 | + | import { EnforcementBadge, RulesetForm } from "../../components/rules"; | |
| 9 | + | import { newRuleset } from "../../lib/rules"; | |
| 10 | + | import { TimeAgo } from "../../components/ui"; | |
| 11 | + | import { work } from "../../lib/services.server"; | |
| 12 | + | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 13 | + | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 14 | + | ||
| 15 | + | export function meta({ params, loaderData, ...args }: Route.MetaArgs) { | |
| 16 | + | const name = loaderData?.existing?.name ?? "New ruleset"; | |
| 17 | + | return page(args, { title: `${name} · Rules · ${params.owner}/${params.repo} · g1t` }); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 21 | + | const viewer = getViewer(context); | |
| 22 | + | const { repo, access } = await requireInsider(context, params, "manage_protection"); | |
| 23 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 24 | + | const seen = await work.seenChecks(path, viewer).catch(() => null); | |
| 25 | + | const base = { | |
| 26 | + | seen: seen?.ok ? seen.value : [], | |
| 27 | + | editable: access.can.manage_protection && !repo.archivedAt, | |
| 28 | + | repository: `${repo.namespace}/${repo.name}`, | |
| 29 | + | }; | |
| 30 | + | if (params.id === "new") return { ...base, existing: null }; | |
| 31 | + | const found = await work.getRuleset({ repo: path }, params.id, viewer); | |
| 32 | + | if (!found.ok && found.error.code === "not_found") throw data(null, { status: 404 }); | |
| 33 | + | const existing = unwrap(found); | |
| 34 | + | // A workspace's ruleset is changed in the workspace's settings. | |
| 35 | + | if (existing.level === "workspace") throw redirect(`/${params.owner}/-/rules/${existing.id}`); | |
| 36 | + | return { ...base, existing }; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 40 | + | assertSameOrigin(request); | |
| 41 | + | const user = requireUser(context, request); | |
| 42 | + | await requireCapability(context, params, "manage_protection"); | |
| 43 | + | const form = await request.formData(); | |
| 44 | + | const owner = { repo: { namespace: params.owner, name: params.repo } }; | |
| 45 | + | const list = `/${params.owner}/${params.repo}/settings/rules`; | |
| 46 | + | if (form.get("intent") === "delete" && params.id !== "new") { | |
| 47 | + | const deleted = await work.deleteRuleset(user, owner, params.id); | |
| 48 | + | return deleted.ok ? redirect(list) : { error: deleted.error.message }; | |
| 49 | + | } | |
| 50 | + | let ruleset: RulesetSpec; | |
| 51 | + | try { | |
| 52 | + | ruleset = JSON.parse(String(form.get("ruleset") ?? "")) as RulesetSpec; | |
| 53 | + | } catch { | |
| 54 | + | return { error: "The ruleset could not be read. Reload the page and try again." }; | |
| 55 | + | } | |
| 56 | + | const saved = await work.saveRuleset(user, owner, ruleset, params.id === "new" ? undefined : params.id); | |
| 57 | + | return saved.ok ? redirect(list) : { error: saved.error.message }; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | export default function RepoRuleset({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 61 | + | const { existing, seen, editable, repository } = loaderData; | |
| 62 | + | const list = `/${params.owner}/${params.repo}/settings/rules`; | |
| 63 | + | return ( | |
| 64 | + | <div className="max-w-4xl"> | |
| 65 | + | <header className="mb-6 border-b border-line pb-5"> | |
| 66 | + | <Link to={list} className="mb-3 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 67 | + | <ArrowLeft size={14} /> Rules | |
| 68 | + | </Link> | |
| 69 | + | <h1 className="flex flex-wrap items-center gap-2 text-lg font-semibold tracking-tight"> | |
| 70 | + | {existing ? existing.name : "New ruleset"} | |
| 71 | + | {existing && <EnforcementBadge enforcement={existing.enforcement} />} | |
| 72 | + | </h1> | |
| 73 | + | <p className="mt-1 text-sm text-muted"> | |
| 74 | + | {existing ? ( | |
| 75 | + | <> | |
| 76 | + | Changed <TimeAgo at={existing.updated_at} /> by <span className="font-mono">{existing.updated_by}</span>. Created by{" "} | |
| 77 | + | <span className="font-mono">{existing.created_by}</span>. | |
| 78 | + | </> | |
| 79 | + | ) : ( | |
| 80 | + | "What may happen to this repository's branches or tags, and what a pull request needs before it merges." | |
| 81 | + | )} | |
| 82 | + | </p> | |
| 83 | + | </header> | |
| 84 | + | <RulesetForm | |
| 85 | + | key={existing?.id ?? "new"} | |
| 86 | + | initial={existing ?? newRuleset("repository")} | |
| 87 | + | level="repository" | |
| 88 | + | existing={existing ?? undefined} | |
| 89 | + | seen={seen} | |
| 90 | + | editable={editable} | |
| 91 | + | error={actionData && "error" in actionData ? actionData.error : null} | |
| 92 | + | backHref={list} | |
| 93 | + | repositoryLabel={repository} | |
| 94 | + | /> | |
| 95 | + | </div> | |
| 96 | + | ); | |
| 97 | + | } |
| 1 | + | import { ChartColumn, Plus, ShieldCheck } from "lucide-react"; | |
| 2 | + | import { Link, data, useSearchParams } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/rules"; | |
| 5 | + | import { page } from "../../lib/meta"; | |
| 6 | + | import { InsightsView, RulesetList } from "../../components/rules"; | |
| 7 | + | import { ButtonLink } from "../../components/ui"; | |
| 8 | + | import { work } from "../../lib/services.server"; | |
| 9 | + | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 10 | + | ||
| 11 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 12 | + | return page(args, { title: `Rules · ${params.owner} · g1t` }); | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 16 | + | const viewer = getViewer(context); | |
| 17 | + | const role = roleIn(viewer, params.owner); | |
| 18 | + | if (!role) throw data(null, { status: 404 }); | |
| 19 | + | const url = new URL(request.url); | |
| 20 | + | const tab = url.searchParams.get("tab") === "insights" ? "insights" : "rulesets"; | |
| 21 | + | const owner = { workspace: params.owner }; | |
| 22 | + | const [rulesets, evaluations] = await Promise.all([ | |
| 23 | + | work.listRulesets(owner, viewer), | |
| 24 | + | tab === "insights" ? work.ruleEvaluations(owner, viewer, { before: url.searchParams.get("before") ?? undefined }) : Promise.resolve(null), | |
| 25 | + | ]); | |
| 26 | + | return { | |
| 27 | + | tab, | |
| 28 | + | rulesets: unwrap(rulesets), | |
| 29 | + | evaluations: evaluations?.ok ? evaluations.value : null, | |
| 30 | + | editable: role === "owner", | |
| 31 | + | }; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | export default function WorkspaceRules({ loaderData, params }: Route.ComponentProps) { | |
| 35 | + | const { tab, rulesets, evaluations, editable } = loaderData; | |
| 36 | + | const base = `/${params.owner}/-/rules`; | |
| 37 | + | const [search] = useSearchParams(); | |
| 38 | + | const tabLink = (name: "rulesets" | "insights") => { | |
| 39 | + | const next = new URLSearchParams(search); | |
| 40 | + | next.delete("before"); | |
| 41 | + | if (name === "rulesets") next.delete("tab"); | |
| 42 | + | else next.set("tab", name); | |
| 43 | + | const query = next.toString(); | |
| 44 | + | return `${base}${query ? `?${query}` : ""}`; | |
| 45 | + | }; | |
| 46 | + | return ( | |
| 47 | + | <div className="mx-auto max-w-4xl"> | |
| 48 | + | <header className="mb-6 border-b border-line pb-5"> | |
| 49 | + | <h1 className="text-lg font-semibold tracking-tight">Rules</h1> | |
| 50 | + | <p className="mt-1 text-sm text-muted"> | |
| 51 | + | Rulesets that hold across {params.owner}'s repositories: which branches and tags they cover, who may bypass them, and what a pull | |
| 52 | + | request needs before it merges. Each repository's own rulesets stack with these. | |
| 53 | + | </p> | |
| 54 | + | </header> | |
| 55 | + | <nav className="mb-6 flex gap-1 border-b border-line" aria-label="Rules"> | |
| 56 | + | {(["rulesets", "insights"] as const).map((name) => ( | |
| 57 | + | <Link | |
| 58 | + | key={name} | |
| 59 | + | to={tabLink(name)} | |
| 60 | + | aria-current={tab === name ? "page" : undefined} | |
| 61 | + | className={`-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm ${tab === name ? "border-accent text-fg" : "border-transparent text-muted hover:text-fg"}`} | |
| 62 | + | > | |
| 63 | + | {name === "rulesets" ? <ShieldCheck size={14} /> : <ChartColumn size={14} />} | |
| 64 | + | {name === "rulesets" ? "Rulesets" : "Insights"} | |
| 65 | + | </Link> | |
| 66 | + | ))} | |
| 67 | + | </nav> | |
| 68 | + | {tab === "rulesets" ? ( | |
| 69 | + | <section> | |
| 70 | + | <div className="mb-3 flex flex-wrap items-center justify-between gap-3"> | |
| 71 | + | <p className="text-sm text-muted">{editable ? "Owners create and change them." : "Owners create and change them; you can read them."}</p> | |
| 72 | + | {editable && ( | |
| 73 | + | <ButtonLink to={`${base}/new`}> | |
| 74 | + | <Plus size={15} /> New ruleset | |
| 75 | + | </ButtonLink> | |
| 76 | + | )} | |
| 77 | + | </div> | |
| 78 | + | <RulesetList | |
| 79 | + | rulesets={rulesets} | |
| 80 | + | level="workspace" | |
| 81 | + | hrefFor={(ruleset) => `${base}/${ruleset.id}`} | |
| 82 | + | empty="No workspace rulesets yet. Each repository's own rulesets still hold." | |
| 83 | + | /> | |
| 84 | + | </section> | |
| 85 | + | ) : evaluations ? ( | |
| 86 | + | <InsightsView page={evaluations} showRepository olderHref={evaluations.next ? `${base}?tab=insights&before=${encodeURIComponent(evaluations.next)}` : null} /> | |
| 87 | + | ) : ( | |
| 88 | + | <p className="text-sm text-muted">Insights could not be read just now.</p> | |
| 89 | + | )} | |
| 90 | + | </div> | |
| 91 | + | ); | |
| 92 | + | } |
| 1 | + | import { ArrowLeft } from "lucide-react"; | |
| 2 | + | import { Link, data, redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { RulesetSpec } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/ruleset"; | |
| 7 | + | import { page } from "../../lib/meta"; | |
| 8 | + | import { EnforcementBadge, RulesetForm } from "../../components/rules"; | |
| 9 | + | import { newRuleset } from "../../lib/rules"; | |
| 10 | + | import { TimeAgo } from "../../components/ui"; | |
| 11 | + | import { work } from "../../lib/services.server"; | |
| 12 | + | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 13 | + | ||
| 14 | + | export function meta({ params, loaderData, ...args }: Route.MetaArgs) { | |
| 15 | + | const name = loaderData?.existing?.name ?? "New ruleset"; | |
| 16 | + | return page(args, { title: `${name} · Rules · ${params.owner} · g1t` }); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 20 | + | const viewer = getViewer(context); | |
| 21 | + | const role = roleIn(viewer, params.owner); | |
| 22 | + | if (!role) throw data(null, { status: 404 }); | |
| 23 | + | const editable = role === "owner"; | |
| 24 | + | if (params.id === "new") { | |
| 25 | + | if (!editable) throw data("Only owners of the workspace can create its rulesets.", { status: 403 }); | |
| 26 | + | return { existing: null, editable }; | |
| 27 | + | } | |
| 28 | + | const found = await work.getRuleset({ workspace: params.owner }, params.id, viewer); | |
| 29 | + | if (!found.ok && found.error.code === "not_found") throw data(null, { status: 404 }); | |
| 30 | + | return { existing: unwrap(found), editable }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 34 | + | assertSameOrigin(request); | |
| 35 | + | const user = requireUser(context, request); | |
| 36 | + | const form = await request.formData(); | |
| 37 | + | const owner = { workspace: params.owner }; | |
| 38 | + | const list = `/${params.owner}/-/rules`; | |
| 39 | + | if (form.get("intent") === "delete" && params.id !== "new") { | |
| 40 | + | const deleted = await work.deleteRuleset(user, owner, params.id); | |
| 41 | + | return deleted.ok ? redirect(list) : { error: deleted.error.message }; | |
| 42 | + | } | |
| 43 | + | let ruleset: RulesetSpec; | |
| 44 | + | try { | |
| 45 | + | ruleset = JSON.parse(String(form.get("ruleset") ?? "")) as RulesetSpec; | |
| 46 | + | } catch { | |
| 47 | + | return { error: "The ruleset could not be read. Reload the page and try again." }; | |
| 48 | + | } | |
| 49 | + | const saved = await work.saveRuleset(user, owner, ruleset, params.id === "new" ? undefined : params.id); | |
| 50 | + | return saved.ok ? redirect(list) : { error: saved.error.message }; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | export default function WorkspaceRuleset({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 54 | + | const { existing, editable } = loaderData; | |
| 55 | + | const list = `/${params.owner}/-/rules`; | |
| 56 | + | return ( | |
| 57 | + | <div className="mx-auto max-w-4xl"> | |
| 58 | + | <header className="mb-6 border-b border-line pb-5"> | |
| 59 | + | <Link to={list} className="mb-3 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 60 | + | <ArrowLeft size={14} /> Rules | |
| 61 | + | </Link> | |
| 62 | + | <h1 className="flex flex-wrap items-center gap-2 text-lg font-semibold tracking-tight"> | |
| 63 | + | {existing ? existing.name : "New ruleset"} | |
| 64 | + | {existing && <EnforcementBadge enforcement={existing.enforcement} />} | |
| 65 | + | </h1> | |
| 66 | + | <p className="mt-1 text-sm text-muted"> | |
| 67 | + | {existing ? ( | |
| 68 | + | <> | |
| 69 | + | Changed <TimeAgo at={existing.updated_at} /> by <span className="font-mono">{existing.updated_by}</span>. | |
| 70 | + | </> | |
| 71 | + | ) : ( | |
| 72 | + | `Rules for the branches or tags of ${params.owner}'s repositories, all or some.` | |
| 73 | + | )} | |
| 74 | + | </p> | |
| 75 | + | </header> | |
| 76 | + | <RulesetForm | |
| 77 | + | key={existing?.id ?? "new"} | |
| 78 | + | initial={existing ?? newRuleset("workspace")} | |
| 79 | + | level="workspace" | |
| 80 | + | existing={existing ?? undefined} | |
| 81 | + | editable={editable} | |
| 82 | + | error={actionData && "error" in actionData ? actionData.error : null} | |
| 83 | + | backHref={list} | |
| 84 | + | /> | |
| 85 | + | </div> | |
| 86 | + | ); | |
| 87 | + | } |
| 1222 | 1222 | pub rulesets: Vec<RulesetSummary>, | |
| 1223 | 1223 | /// Whether merging joins the merge queue. | |
| 1224 | 1224 | pub merge_queue: bool, | |
| 1225 | + | /// What the active rules ask, as they stack: the approvals a merge | |
| 1226 | + | /// needs, whether it must be up to date, and whether a merger may merge | |
| 1227 | + | /// past required checks that have not passed. | |
| 1228 | + | #[serde(default)] | |
| 1229 | + | pub required_approvals: u32, | |
| 1230 | + | #[serde(default)] | |
| 1231 | + | pub strict: bool, | |
| 1232 | + | #[serde(default)] | |
| 1233 | + | pub allow_bypass_on_merge: bool, | |
| 1225 | 1234 | } | |
| 1226 | 1235 | ||
| 1227 | 1236 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] |
| 395 | 395 | catchUpJob: (pullId) => call("catch_up_job", { pullId }), | |
| 396 | 396 | wakeForMessages: (pullId) => call("wake_for_messages", { pullId }), | |
| 397 | 397 | getSettings: (repo, viewer) => call("get_settings", { repo, viewer }), | |
| 398 | + | // Rulesets travel in snake_case (rules.ts). | |
| 399 | + | listRulesets: (owner, viewer, includeParents = false) => | |
| 400 | + | call("list_rulesets", { viewer, ...owner, include_parents: includeParents }), | |
| 401 | + | getRuleset: (owner, id, viewer) => call("get_ruleset", { viewer, ...owner, id }), | |
| 402 | + | saveRuleset: (actor, owner, ruleset, id) => call("save_ruleset", { actor, ...owner, id: id ?? null, ruleset }), | |
| 403 | + | deleteRuleset: (actor, owner, id) => call("delete_ruleset", { actor, ...owner, id }), | |
| 404 | + | effectiveRules: (repo, name, viewer, target = "branch") => call("effective_rules", { viewer, repo, name, target }), | |
| 405 | + | ruleEvaluations: (owner, viewer, filter = {}) => call("rule_evaluations", { viewer, ...owner, ...filter }), | |
| 398 | 406 | seenChecks: (repo, viewer) => call("seen_checks", { repo, viewer }), | |
| 399 | 407 | codeownersErrors: (repo, viewer, ref) => call("codeowners_errors", { repo, viewer, ref: ref ?? null }), | |
| 400 | 408 | updateSettings: (actor, repo, settings) => |
| 26 | 26 | export * from "./projects"; | |
| 27 | 27 | export * from "./repos"; | |
| 28 | 28 | export * from "./result"; | |
| 29 | + | export * from "./rules"; | |
| 29 | 30 | export * from "./runner"; | |
| 30 | 31 | export * from "./runners"; | |
| 31 | 32 | export * from "./scopes"; |
| 1 | + | import type { RepoPath } from "./repos"; | |
| 2 | + | import type { Result } from "./result"; | |
| 3 | + | import type { User, Viewer } from "./identity"; | |
| 4 | + | import type { ConfidenceLevel } from "./work"; | |
| 5 | + | ||
| 6 | + | /** | |
| 7 | + | * Rulesets: what may happen to a repository's branches and tags, and what a | |
| 8 | + | * pull request needs before it merges. A ruleset belongs to a repository or | |
| 9 | + | * to a workspace (and through it to the repositories it selects), targets | |
| 10 | + | * branches or tags by name, is `active`, `evaluate` (a dry run that records | |
| 11 | + | * what it would have refused) or `disabled`, lists who may bypass it, and | |
| 12 | + | * holds rules. Rulesets stack: every rule of each holds. | |
| 13 | + | * | |
| 14 | + | * Mirrors `crates/contracts/src/rules.rs`. Rulesets travel in the shape the | |
| 15 | + | * API shows them, `snake_case`, so an export imports anywhere unchanged. | |
| 16 | + | */ | |
| 17 | + | ||
| 18 | + | export const DEFAULT_BRANCH = "~DEFAULT_BRANCH"; | |
| 19 | + | export const ALL = "~ALL"; | |
| 20 | + | export const MAX_RULESETS = 75; | |
| 21 | + | ||
| 22 | + | export type Enforcement = "active" | "evaluate" | "disabled"; | |
| 23 | + | export type Target = "branch" | "tag"; | |
| 24 | + | export type Level = "repository" | "workspace"; | |
| 25 | + | export type AppliesTo = "everyone" | "agents" | "people"; | |
| 26 | + | export type BypassActorKind = "role" | "team" | "user" | "token" | "g1t"; | |
| 27 | + | export type BypassMode = "always" | "pull_requests"; | |
| 28 | + | export type MergeMethod = "merge" | "squash" | "rebase"; | |
| 29 | + | export type Integration = "actions" | "deployments" | "security" | "g1t"; | |
| 30 | + | export type PatternOperator = "starts_with" | "ends_with" | "contains" | "regex"; | |
| 31 | + | ||
| 32 | + | export type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; | |
| 33 | + | ||
| 34 | + | export type RefCondition = { include: string[]; exclude: string[] }; | |
| 35 | + | export type RepositoryCondition = { | |
| 36 | + | include: string[]; | |
| 37 | + | exclude: string[]; | |
| 38 | + | visibility: "any" | "public" | "private"; | |
| 39 | + | topics: string[]; | |
| 40 | + | }; | |
| 41 | + | export type Conditions = { ref_name: RefCondition; repository?: RepositoryCondition }; | |
| 42 | + | export type BypassActor = { kind: BypassActorKind; value: string; mode: BypassMode }; | |
| 43 | + | ||
| 44 | + | export type PullRequestParameters = { | |
| 45 | + | required_approvals: number; | |
| 46 | + | count_agent_approvals: boolean; | |
| 47 | + | dismiss_stale_reviews_on_push: boolean; | |
| 48 | + | require_code_owner_review: boolean; | |
| 49 | + | require_last_push_approval: boolean; | |
| 50 | + | allowed_merge_methods: MergeMethod[]; | |
| 51 | + | /** Only the ruleset made from branch protection that did not refuse pushes. */ | |
| 52 | + | allow_direct_pushes?: boolean; | |
| 53 | + | }; | |
| 54 | + | export type RulesetCheck = { context: string; integration?: Integration }; | |
| 55 | + | export type StatusChecksParameters = { | |
| 56 | + | checks: RulesetCheck[]; | |
| 57 | + | strict: boolean; | |
| 58 | + | paths: string[]; | |
| 59 | + | allow_bypass_on_merge: boolean; | |
| 60 | + | }; | |
| 61 | + | export type MergeQueueParameters = { | |
| 62 | + | merge_method: MergeMethod; | |
| 63 | + | max_entries_to_build: number; | |
| 64 | + | min_entries_to_merge: number; | |
| 65 | + | min_entries_wait_minutes: number; | |
| 66 | + | check_response_timeout_minutes: number; | |
| 67 | + | }; | |
| 68 | + | export type PatternParameters = { name: string; operator: PatternOperator; pattern: string; negate: boolean }; | |
| 69 | + | export type WeeklyWindow = { days: Weekday[]; start: string; end: string }; | |
| 70 | + | export type Period = { start: string; end?: string | null; reason: string }; | |
| 71 | + | export type MergeWindowParameters = { | |
| 72 | + | time_zone: string; | |
| 73 | + | windows: WeeklyWindow[]; | |
| 74 | + | freezes: Period[]; | |
| 75 | + | exceptions: Period[]; | |
| 76 | + | }; | |
| 77 | + | ||
| 78 | + | /** Every rule type, with its parameters. */ | |
| 79 | + | export type Rule = | |
| 80 | + | | { type: "creation"; parameters: Record<string, never> } | |
| 81 | + | | { type: "update"; parameters: Record<string, never> } | |
| 82 | + | | { type: "deletion"; parameters: Record<string, never> } | |
| 83 | + | | { type: "non_fast_forward"; parameters: Record<string, never> } | |
| 84 | + | | { type: "required_linear_history"; parameters: Record<string, never> } | |
| 85 | + | | { type: "required_signatures"; parameters: Record<string, never> } | |
| 86 | + | | { type: "pull_request"; parameters: PullRequestParameters } | |
| 87 | + | | { type: "required_status_checks"; parameters: StatusChecksParameters } | |
| 88 | + | | { type: "merge_queue"; parameters: MergeQueueParameters } | |
| 89 | + | | { type: "required_deployments"; parameters: { environments: string[] } } | |
| 90 | + | | { type: "commit_message_pattern"; parameters: PatternParameters } | |
| 91 | + | | { type: "commit_author_email_pattern"; parameters: PatternParameters } | |
| 92 | + | | { type: "committer_email_pattern"; parameters: PatternParameters } | |
| 93 | + | | { type: "branch_name_pattern"; parameters: PatternParameters } | |
| 94 | + | | { type: "tag_name_pattern"; parameters: PatternParameters } | |
| 95 | + | | { type: "file_path_restriction"; parameters: { restricted_file_paths: string[] } } | |
| 96 | + | | { type: "file_extension_restriction"; parameters: { restricted_file_extensions: string[] } } | |
| 97 | + | | { type: "max_file_size"; parameters: { max_file_size_mb: number } } | |
| 98 | + | | { type: "max_file_path_length"; parameters: { max_file_path_length: number } } | |
| 99 | + | | { type: "max_files_changed"; parameters: { max_files: number } } | |
| 100 | + | | { type: "secret_scanning"; parameters: Record<string, never> } | |
| 101 | + | | { type: "confidence_threshold"; parameters: { minimum: ConfidenceLevel; required_approvals: number } } | |
| 102 | + | | { type: "cost_cap"; parameters: { max_usd: number } } | |
| 103 | + | | { type: "path_review"; parameters: { paths: string[]; required_approvals: number; team?: string | null } } | |
| 104 | + | | { type: "merge_window"; parameters: MergeWindowParameters } | |
| 105 | + | | { type: "agent_auto_merge"; parameters: { allowed: boolean; minimum_confidence?: ConfidenceLevel | null } }; | |
| 106 | + | ||
| 107 | + | export type RuleType = Rule["type"]; | |
| 108 | + | export type RuleEntry = Rule & { applies_to: AppliesTo }; | |
| 109 | + | ||
| 110 | + | /** What a ruleset says: what is created, changed, exported and imported. */ | |
| 111 | + | export type RulesetSpec = { | |
| 112 | + | name: string; | |
| 113 | + | enforcement: Enforcement; | |
| 114 | + | target: Target; | |
| 115 | + | conditions: Conditions; | |
| 116 | + | bypass_actors: BypassActor[]; | |
| 117 | + | rules: RuleEntry[]; | |
| 118 | + | }; | |
| 119 | + | ||
| 120 | + | export type Ruleset = RulesetSpec & { | |
| 121 | + | id: string; | |
| 122 | + | level: Level; | |
| 123 | + | workspace: string; | |
| 124 | + | repo_id?: string; | |
| 125 | + | repository?: string; | |
| 126 | + | /** `branch_protection` for the one made from branch protection settings. */ | |
| 127 | + | source?: string; | |
| 128 | + | created_by: string; | |
| 129 | + | created_at: string; | |
| 130 | + | updated_by: string; | |
| 131 | + | updated_at: string; | |
| 132 | + | }; | |
| 133 | + | ||
| 134 | + | export type RulesetSummary = { | |
| 135 | + | id: string; | |
| 136 | + | name: string; | |
| 137 | + | level: Level; | |
| 138 | + | enforcement: Enforcement; | |
| 139 | + | bypass_actors: BypassActor[]; | |
| 140 | + | }; | |
| 141 | + | ||
| 142 | + | export type EffectiveRule = RuleEntry & { | |
| 143 | + | ruleset_id: string; | |
| 144 | + | ruleset_name: string; | |
| 145 | + | level: Level; | |
| 146 | + | enforcement: Enforcement; | |
| 147 | + | }; | |
| 148 | + | ||
| 149 | + | export type EffectiveRules = { | |
| 150 | + | name: string; | |
| 151 | + | target: Target; | |
| 152 | + | default_branch: boolean; | |
| 153 | + | rules: EffectiveRule[]; | |
| 154 | + | rulesets: RulesetSummary[]; | |
| 155 | + | }; | |
| 156 | + | ||
| 157 | + | export type RuleVerdict = "pass" | "fail" | "bypass"; | |
| 158 | + | export type Action = "push" | "merge" | "create_ref" | "delete_ref" | "rename_ref" | "commit"; | |
| 159 | + | ||
| 160 | + | export type Violation = { | |
| 161 | + | rule: string; | |
| 162 | + | ruleset_id: string; | |
| 163 | + | ruleset_name: string; | |
| 164 | + | enforcement: Enforcement; | |
| 165 | + | message: string; | |
| 166 | + | remedy: string; | |
| 167 | + | }; | |
| 168 | + | ||
| 169 | + | export type Evaluation = { | |
| 170 | + | id: string; | |
| 171 | + | repo_id: string; | |
| 172 | + | workspace: string; | |
| 173 | + | ruleset_id: string; | |
| 174 | + | ruleset_name: string; | |
| 175 | + | enforcement: Enforcement; | |
| 176 | + | action: Action; | |
| 177 | + | git_ref: string; | |
| 178 | + | actor: string; | |
| 179 | + | actor_kind: string; | |
| 180 | + | verdict: RuleVerdict; | |
| 181 | + | violations: Violation[]; | |
| 182 | + | number: number | null; | |
| 183 | + | sha: string | null; | |
| 184 | + | repository: string; | |
| 185 | + | created_at: string; | |
| 186 | + | }; | |
| 187 | + | ||
| 188 | + | export type Insights = { | |
| 189 | + | days: number; | |
| 190 | + | total: number; | |
| 191 | + | passed: number; | |
| 192 | + | blocked: number; | |
| 193 | + | would_block: number; | |
| 194 | + | bypassed: number; | |
| 195 | + | by_ruleset: { | |
| 196 | + | ruleset_id: string; | |
| 197 | + | ruleset_name: string; | |
| 198 | + | enforcement: Enforcement; | |
| 199 | + | total: number; | |
| 200 | + | blocked: number; | |
| 201 | + | would_block: number; | |
| 202 | + | bypassed: number; | |
| 203 | + | }[]; | |
| 204 | + | by_rule: { rule: string; count: number }[]; | |
| 205 | + | }; | |
| 206 | + | ||
| 207 | + | export type EvaluationPage = { evaluations: Evaluation[]; next: string | null; insights: Insights }; | |
| 208 | + | ||
| 209 | + | /** What a pull request's merge box shows of the rules of its base. */ | |
| 210 | + | export type MergeRules = { | |
| 211 | + | unmet: Violation[]; | |
| 212 | + | bypassable: Violation[]; | |
| 213 | + | evaluate: Violation[]; | |
| 214 | + | rulesets: RulesetSummary[]; | |
| 215 | + | merge_queue: boolean; | |
| 216 | + | /** What the active rules ask, as they stack. */ | |
| 217 | + | required_approvals: number; | |
| 218 | + | strict: boolean; | |
| 219 | + | allow_bypass_on_merge: boolean; | |
| 220 | + | }; | |
| 221 | + | ||
| 222 | + | /** Whose rulesets: a repository's or a workspace's. */ | |
| 223 | + | export type RulesetOwner = { repo: RepoPath } | { workspace: string }; | |
| 224 | + | ||
| 225 | + | export type EvaluationFilter = { | |
| 226 | + | ruleset_id?: string; | |
| 227 | + | verdict?: RuleVerdict; | |
| 228 | + | problems_only?: boolean; | |
| 229 | + | before?: string; | |
| 230 | + | limit?: number; | |
| 231 | + | }; | |
| 232 | + | ||
| 233 | + | export interface RulesApi { | |
| 234 | + | listRulesets(owner: RulesetOwner, viewer: Viewer, includeParents?: boolean): Promise<Result<Ruleset[]>>; | |
| 235 | + | getRuleset(owner: RulesetOwner, id: string, viewer: Viewer): Promise<Result<Ruleset>>; | |
| 236 | + | /** Creates one (no `id`) or replaces one. */ | |
| 237 | + | saveRuleset(actor: User, owner: RulesetOwner, ruleset: RulesetSpec, id?: string): Promise<Result<Ruleset>>; | |
| 238 | + | deleteRuleset(actor: User, owner: RulesetOwner, id: string): Promise<Result<boolean>>; | |
| 239 | + | effectiveRules(repo: RepoPath, name: string, viewer: Viewer, target?: Target): Promise<Result<EffectiveRules>>; | |
| 240 | + | ruleEvaluations(owner: RulesetOwner, viewer: Viewer, filter?: EvaluationFilter): Promise<Result<EvaluationPage>>; | |
| 241 | + | } |
| 2 | 2 | import type { User, Viewer } from "./identity"; | |
| 3 | 3 | import type { PullBranchUpdate, RepoPath } from "./repos"; | |
| 4 | 4 | import type { Result } from "./result"; | |
| 5 | + | import type { MergeRules, RulesApi } from "./rules"; | |
| 5 | 6 | ||
| 6 | 7 | /** | |
| 7 | 8 | * The filter on lists of issues and pull requests. An open pull request is | |
| ⋯ | |||
| 215 | 216 | * not seeing through. | |
| 216 | 217 | */ | |
| 217 | 218 | confidence?: Confidence | null; | |
| 219 | + | /** Who last moved its head (a user id), and when; absent until a push after rulesets arrived. */ | |
| 220 | + | headPushedBy?: string; | |
| 221 | + | headPushedAt?: string; | |
| 218 | 222 | }; | |
| 219 | 223 | ||
| 220 | 224 | /** How sure g1t is that an agent's change is right. */ | |
| ⋯ | |||
| 464 | 468 | * merges into) and whose approval is still needed. Absent without one. | |
| 465 | 469 | */ | |
| 466 | 470 | codeOwners?: PullCodeOwners | null; | |
| 471 | + | /** | |
| 472 | + | * The rules of the branch it merges into it does not meet yet, for whoever | |
| 473 | + | * is looking: what refuses the merge, what they may bypass, and what rulesets | |
| 474 | + | * in evaluate would refuse. Absent once it is closed or merged. | |
| 475 | + | */ | |
| 476 | + | rules?: MergeRules | null; | |
| 467 | 477 | }; | |
| 468 | 478 | ||
| 469 | 479 | /** Where a required check stands on a commit; `expected` when nothing has reported it yet. */ | |
| ⋯ | |||
| 818 | 828 | }; | |
| 819 | 829 | ||
| 820 | 830 | /** Issues, pull requests, comments and sessions. */ | |
| 821 | − | export interface WorkApi { | |
| 831 | + | export interface WorkApi extends RulesApi { | |
| 822 | 832 | openIssue(actor: User, repo: RepoPath, input: OpenIssueInput): Promise<Result<Issue>>; | |
| 823 | 833 | /** | |
| 824 | 834 | * Opens an issue to put g1t on at once: refused, with nothing | |
| ⋯ | |||
| 1017 | 1027 | number: number, | |
| 1018 | 1028 | options?: { | |
| 1019 | 1029 | keepIssueOpen?: boolean; | |
| 1020 | − | /** Merge although required checks have not passed, where the repository allows bypassing them. */ | |
| 1030 | + | /** Merge although required checks have not passed, where the rule requiring them allows it. */ | |
| 1021 | 1031 | ignoreChecks?: boolean; | |
| 1032 | + | /** Merge past rules a ruleset lets the actor bypass. Recorded as a bypass. */ | |
| 1033 | + | bypassRules?: boolean; | |
| 1022 | 1034 | }, | |
| 1023 | 1035 | ): Promise<Result<Pull>>; | |
| 1024 | 1036 | /** | |
| 1454 | 1454 | let code_owners = self.pull_code_owners(&pull, &comments, &settings).await?; | |
| 1455 | 1455 | Ok(Outcome::Ok(PullDetail { | |
| 1456 | 1456 | required_checks: required_checks(&settings.required_checks, &statuses), | |
| 1457 | − | rules: gate.map(|gate| rulesets::merge_rules(&gate.judged, &gate.requirements)), | |
| 1457 | + | rules: gate.map(|gate| rulesets::merge_rules(&gate.judged, &gate.requirements, pull.base_branch(&repo.default_branch) == repo.default_branch)), | |
| 1458 | 1458 | code_owners, | |
| 1459 | 1459 | comments, | |
| 1460 | 1460 | checks, |
| 209 | 209 | } | |
| 210 | 210 | ||
| 211 | 211 | /// What the merge box shows: each rule not met, and how to meet it. | |
| 212 | − | pub(crate) fn merge_rules(judged: &[Judged], requirements: &Requirements) -> MergeRules { | |
| 213 | − | let mut out = MergeRules { merge_queue: requirements.merge_queue.is_some(), ..MergeRules::default() }; | |
| 212 | + | /// `default_branch`: whether it merges into the default branch, where the | |
| 213 | + | /// merge queue lands. | |
| 214 | + | pub(crate) fn merge_rules(judged: &[Judged], requirements: &Requirements, default_branch: bool) -> MergeRules { | |
| 215 | + | let mut out = MergeRules { | |
| 216 | + | merge_queue: requirements.merge_queue.is_some() && default_branch, | |
| 217 | + | required_approvals: requirements.required_approvals, | |
| 218 | + | strict: requirements.strict, | |
| 219 | + | allow_bypass_on_merge: requirements.allow_bypass_on_merge, | |
| 220 | + | ..MergeRules::default() | |
| 221 | + | }; | |
| 214 | 222 | for one in judged { | |
| 215 | 223 | match (one.enforcement, one.verdict()) { | |
| 216 | 224 | (Enforcement::Active, Verdict::Fail) => out.unmet.extend(one.violations.iter().cloned()), |