Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 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"; | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 62 | import { ErrorText, SubmitButton, TimeAgo } from "./ui"; |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 63 | import { Badge } from "./ui/badge"; |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 64 | import { Button } from "./ui/button"; |
| 65 | import { Card } from "./ui/card"; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 66 | import { CheckboxOption } from "./ui/checkbox"; |
| 67 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 68 | import { Input } from "./ui/input"; | |
| 69 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 70 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 71 | import { Switch } from "./ui/switch"; | |
| 72 | ||
| 73 | // --- Small pieces ----------------------------------------------------------- | |
| 74 | ||
| 75 | const ENFORCEMENT: Record<Enforcement, { label: string; about: string; tone: "accent" | "info" | "neutral" }> = { | |
| 76 | active: { label: "Active", about: "Its rules hold: what breaks them is refused.", tone: "accent" }, | |
| 77 | evaluate: { label: "Evaluate", about: "A dry run: nothing is refused, and what would have been is recorded in Insights.", tone: "info" }, | |
| 78 | disabled: { label: "Disabled", about: "Kept, but not evaluated.", tone: "neutral" }, | |
| 79 | }; | |
| 80 | ||
| 81 | export function EnforcementBadge({ enforcement }: { enforcement: Enforcement }) { | |
| 82 | const info = ENFORCEMENT[enforcement]; | |
| 83 | return <Badge tone={info.tone}>{info.label}</Badge>; | |
| 84 | } | |
| 85 | ||
| 86 | /** A list of patterns or names, as removable chips and a field to add one. */ | |
| 87 | export function PatternList({ | |
| 88 | values, | |
| 89 | onChange, | |
| 90 | placeholder, | |
| 91 | quick = [], | |
| 92 | disabled, | |
| 93 | label, | |
| 94 | mono = true, | |
| 95 | }: { | |
| 96 | values: string[]; | |
| 97 | onChange: (values: string[]) => void; | |
| 98 | placeholder: string; | |
| 99 | quick?: { value: string; label: string }[]; | |
| 100 | disabled?: boolean; | |
| 101 | label: string; | |
| 102 | mono?: boolean; | |
| 103 | }) { | |
| 104 | const [typed, setTyped] = useState(""); | |
| 105 | const add = (value: string) => { | |
| 106 | const trimmed = value.trim(); | |
| 107 | if (!trimmed || values.includes(trimmed)) return; | |
| 108 | onChange([...values, trimmed]); | |
| 109 | setTyped(""); | |
| 110 | }; | |
| 111 | return ( | |
| 112 | <div className="space-y-2"> | |
| 113 | {values.length > 0 && ( | |
| 114 | <ul className="flex flex-wrap gap-1.5" aria-label={label}> | |
| 115 | {values.map((value) => ( | |
| 116 | <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"> | |
| 117 | <span className={cn("truncate", mono && !value.startsWith("~") && "font-mono text-[0.8125rem]")}>{patternLabel(value)}</span> | |
| 118 | {!disabled && ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 119 | <Button |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 120 | type="button" |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 121 | aria-label={`Remove ${patternLabel(value)}`} variant="ghost" size="inline" className="rounded p-0.5 text-faint hover:bg-surface" onClick={() => onChange(values.filter((other) => other !== value))}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 122 | <X size={13} /> |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 123 | </Button> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 124 | )} |
| 125 | </li> | |
| 126 | ))} | |
| 127 | </ul> | |
| 128 | )} | |
| 129 | {!disabled && ( | |
| 130 | <div className="flex flex-wrap items-center gap-2"> | |
| 131 | <Input | |
| 132 | value={typed} | |
| 133 | aria-label={label} | |
| 134 | placeholder={placeholder} | |
| 135 | className="w-56 max-w-full font-mono text-[0.8125rem]" | |
| 136 | onChange={(event) => setTyped(event.target.value)} | |
| 137 | onKeyDown={(event) => { | |
| 138 | if (event.key === "Enter") { | |
| 139 | event.preventDefault(); | |
| 140 | add(typed); | |
| 141 | } | |
| 142 | }} | |
| 143 | /> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 144 | <Button type="button" variant="outline" onClick={() => add(typed)} disabled={!typed.trim()}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 145 | <Plus size={14} /> Add |
| 146 | </Button> | |
| 147 | {quick | |
| 148 | .filter((option) => !values.includes(option.value)) | |
| 149 | .map((option) => ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 150 | <Button |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 151 | key={option.value} |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 152 | type="button" variant="outline" size="inline" className="rounded-full border-dashed px-2.5 py-1 text-xs text-muted" onClick={() => add(option.value)}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 153 | + {option.label} |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 154 | </Button> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 155 | ))} |
| 156 | </div> | |
| 157 | )} | |
| 158 | </div> | |
| 159 | ); | |
| 160 | } | |
| 161 | ||
| 162 | function Choice<T extends string>({ | |
| 163 | value, | |
| 164 | options, | |
| 165 | onChange, | |
| 166 | label, | |
| 167 | disabled, | |
| 168 | className, | |
| 169 | }: { | |
| 170 | value: T; | |
| 171 | options: { value: T; label: string }[]; | |
| 172 | onChange: (value: T) => void; | |
| 173 | label: string; | |
| 174 | disabled?: boolean; | |
| 175 | className?: string; | |
| 176 | }) { | |
| 177 | return ( | |
| 178 | <Select value={value} onValueChange={(next) => onChange(next as T)} disabled={disabled}> | |
| 179 | <SelectTrigger size="sm" aria-label={label} className={cn("w-auto min-w-28", className)}> | |
| 180 | <SelectValue /> | |
| 181 | </SelectTrigger> | |
| 182 | <SelectContent> | |
| 183 | {options.map((option) => ( | |
| 184 | <SelectItem key={option.value} value={option.value}> | |
| 185 | {option.label} | |
| 186 | </SelectItem> | |
| 187 | ))} | |
| 188 | </SelectContent> | |
| 189 | </Select> | |
| 190 | ); | |
| 191 | } | |
| 192 | ||
| 193 | function Toggle({ | |
| 194 | checked, | |
| 195 | onChange, | |
| 196 | title, | |
| 197 | about, | |
| 198 | disabled, | |
| 199 | }: { | |
| 200 | checked: boolean; | |
| 201 | onChange: (checked: boolean) => void; | |
| 202 | title: string; | |
| 203 | about?: ReactNode; | |
| 204 | disabled?: boolean; | |
| 205 | }) { | |
| 206 | return ( | |
| 207 | <label className="flex cursor-pointer items-start justify-between gap-4 py-1.5"> | |
| 208 | <span className="min-w-0"> | |
| 209 | <span className="block text-sm text-fg">{title}</span> | |
| 210 | {about && <span className="mt-0.5 block text-xs text-muted">{about}</span>} | |
| 211 | </span> | |
| 212 | <Switch size="sm" checked={checked} onCheckedChange={(next) => onChange(next === true)} disabled={disabled} className="mt-0.5" aria-label={title} /> | |
| 213 | </label> | |
| 214 | ); | |
| 215 | } | |
| 216 | ||
| 217 | function NumberField({ | |
| 218 | value, | |
| 219 | onChange, | |
| 220 | label, | |
| 221 | min, | |
| 222 | max, | |
| 223 | step = 1, | |
| 224 | suffix, | |
| 225 | disabled, | |
| 226 | }: { | |
| 227 | value: number; | |
| 228 | onChange: (value: number) => void; | |
| 229 | label: string; | |
| 230 | min: number; | |
| 231 | max: number; | |
| 232 | step?: number; | |
| 233 | suffix?: string; | |
| 234 | disabled?: boolean; | |
| 235 | }) { | |
| 236 | return ( | |
| 237 | <label className="flex items-center justify-between gap-4 py-1.5"> | |
| 238 | <span className="text-sm text-fg">{label}</span> | |
| 239 | <span className="flex items-center gap-2"> | |
| 240 | <Input | |
| 241 | type="number" | |
| 242 | min={min} | |
| 243 | max={max} | |
| 244 | step={step} | |
| 245 | value={Number.isFinite(value) ? value : ""} | |
| 246 | disabled={disabled} | |
| 247 | className="w-24 text-right" | |
| 248 | onChange={(event) => onChange(Number(event.target.value))} | |
| 249 | /> | |
| 250 | {suffix && <span className="text-sm text-muted">{suffix}</span>} | |
| 251 | </span> | |
| 252 | </label> | |
| 253 | ); | |
| 254 | } | |
| 255 | ||
| 256 | // --- Rule parameters ---------------------------------------------------------- | |
| 257 | ||
| 258 | const OPERATORS: { value: PatternOperator; label: string }[] = [ | |
| 259 | { value: "starts_with", label: "Starts with" }, | |
| 260 | { value: "ends_with", label: "Ends with" }, | |
| 261 | { value: "contains", label: "Contains" }, | |
| 262 | { value: "regex", label: "Matches regex" }, | |
| 263 | ]; | |
| 264 | const LEVELS: { value: ConfidenceLevel; label: string }[] = [ | |
| 265 | { value: "low", label: "Low" }, | |
| 266 | { value: "medium", label: "Medium" }, | |
| 267 | { value: "high", label: "High" }, | |
| 268 | ]; | |
| 269 | const DAYS: Weekday[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; | |
| 270 | const DAY_LABEL: Record<Weekday, string> = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" }; | |
| 271 | ||
| 272 | /** An RFC 3339 time as a datetime-local value, in the viewer's zone. */ | |
| 273 | function toLocal(iso: string | null | undefined): string { | |
| 274 | if (!iso) return ""; | |
| 275 | const date = new Date(iso); | |
| 276 | if (Number.isNaN(date.getTime())) return ""; | |
| 277 | const offset = date.getTimezoneOffset() * 60_000; | |
| 278 | return new Date(date.getTime() - offset).toISOString().slice(0, 16); | |
| 279 | } | |
| 280 | ||
| 281 | function fromLocal(value: string): string | null { | |
| 282 | if (!value) return null; | |
| 283 | const date = new Date(value); | |
| 284 | return Number.isNaN(date.getTime()) ? null : date.toISOString(); | |
| 285 | } | |
| 286 | ||
| 287 | type Periods = { start: string; end?: string | null; reason: string }[]; | |
| 288 | ||
| 289 | function PeriodList({ | |
| 290 | periods, | |
| 291 | onChange, | |
| 292 | label, | |
| 293 | disabled, | |
| 294 | allowOpen, | |
| 295 | }: { | |
| 296 | periods: Periods; | |
| 297 | onChange: (periods: Periods) => void; | |
| 298 | label: string; | |
| 299 | disabled?: boolean; | |
| 300 | allowOpen?: boolean; | |
| 301 | }) { | |
| 302 | return ( | |
| 303 | <div className="space-y-2"> | |
| 304 | {periods.map((period, index) => ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 305 | <Card key={index} tone="plain" radius="lg" className="flex flex-wrap items-center gap-2 p-2"> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 306 | <Input |
| 307 | type="datetime-local" | |
| 308 | aria-label={`${label} ${index + 1} starts`} | |
| 309 | value={toLocal(period.start)} | |
| 310 | disabled={disabled} | |
| 311 | className="w-auto" | |
| 312 | onChange={(event) => onChange(periods.map((one, at) => (at === index ? { ...one, start: fromLocal(event.target.value) ?? one.start } : one)))} | |
| 313 | /> | |
| 314 | <span className="text-sm text-muted">to</span> | |
| 315 | <Input | |
| 316 | type="datetime-local" | |
| 317 | aria-label={`${label} ${index + 1} ends`} | |
| 318 | value={toLocal(period.end)} | |
| 319 | disabled={disabled} | |
| 320 | className="w-auto" | |
| 321 | onChange={(event) => onChange(periods.map((one, at) => (at === index ? { ...one, end: fromLocal(event.target.value) } : one)))} | |
| 322 | /> | |
| 323 | <Input | |
| 324 | aria-label={`${label} ${index + 1} reason`} | |
| 325 | placeholder="Why" | |
| 326 | value={period.reason} | |
| 327 | disabled={disabled} | |
| 328 | className="w-40 grow" | |
| 329 | onChange={(event) => onChange(periods.map((one, at) => (at === index ? { ...one, reason: event.target.value } : one)))} | |
| 330 | /> | |
| 331 | {!disabled && ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 332 | <Button type="button" aria-label={`Remove ${label.toLowerCase()} ${index + 1}`} variant="ghost" size="inline" className="rounded p-1 text-faint" onClick={() => onChange(periods.filter((_, at) => at !== index))}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 333 | <X size={14} /> |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 334 | </Button> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 335 | )} |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 336 | </Card> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 337 | ))} |
| 338 | {!disabled && ( | |
| 339 | <div className="flex flex-wrap gap-2"> | |
| 340 | <Button | |
| 341 | type="button" | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 342 | variant="outline" |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 343 | onClick={() => onChange([...periods, { start: new Date().toISOString(), end: new Date(Date.now() + 86_400_000).toISOString(), reason: "" }])} |
| 344 | > | |
| 345 | <Plus size={14} /> {label} | |
| 346 | </Button> | |
| 347 | {allowOpen && ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 348 | <Button type="button" variant="outline" onClick={() => onChange([...periods, { start: new Date().toISOString(), end: null, reason: "Incident" }])}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 349 | <ShieldAlert size={14} /> Freeze now, until lifted |
| 350 | </Button> | |
| 351 | )} | |
| 352 | </div> | |
| 353 | )} | |
| 354 | </div> | |
| 355 | ); | |
| 356 | } | |
| 357 | ||
| 358 | /** The parameters of one rule, as fields. */ | |
| 359 | function Parameters({ | |
| 360 | entry, | |
| 361 | onChange, | |
| 362 | seen, | |
| 363 | disabled, | |
| 364 | }: { | |
| 365 | entry: RuleEntry; | |
| 366 | onChange: (entry: RuleEntry) => void; | |
| 367 | seen: SeenCheck[]; | |
| 368 | disabled?: boolean; | |
| 369 | }) { | |
| 370 | // Each case narrows `entry` and writes back its own parameters. | |
| 371 | const set = (parameters: object) => onChange({ ...entry, parameters } as RuleEntry); | |
| 372 | switch (entry.type) { | |
| 373 | case "pull_request": { | |
| 374 | const p = entry.parameters; | |
| 375 | const methods: MergeMethod[] = ["merge", "squash", "rebase"]; | |
| 376 | return ( | |
| 377 | <div className="divide-y divide-line"> | |
| 378 | <NumberField label="Required approvals" min={0} max={10} value={p.required_approvals} disabled={disabled} onChange={(value) => set({ ...p, required_approvals: value })} /> | |
| 379 | <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 })} /> | |
| 380 | <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 })} /> | |
| 381 | <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 })} /> | |
| 382 | <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 })} /> | |
| 383 | {p.allow_direct_pushes && ( | |
| 384 | <Toggle | |
| 385 | title="Still allow pushes straight to the branch" | |
| 386 | about="Kept from branch protection that did not require pull requests. Turn it off to require them." | |
| 387 | checked={p.allow_direct_pushes} | |
| 388 | disabled={disabled} | |
| 389 | onChange={(value) => set({ ...p, allow_direct_pushes: value })} | |
| 390 | /> | |
| 391 | )} | |
| 392 | <div className="py-2"> | |
| 393 | <p className="text-sm text-fg">Allowed merge methods</p> | |
| 394 | <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> | |
| 395 | <div className="mt-2 flex flex-wrap gap-4"> | |
| 396 | {methods.map((method) => ( | |
| 397 | <CheckboxOption | |
| 398 | key={method} | |
| 399 | label={method[0]!.toUpperCase() + method.slice(1)} | |
| 400 | checked={p.allowed_merge_methods.includes(method)} | |
| 401 | disabled={disabled} | |
| 402 | onCheckedChange={(checked) => | |
| 403 | set({ | |
| 404 | ...p, | |
| 405 | allowed_merge_methods: checked | |
| 406 | ? [...p.allowed_merge_methods, method] | |
| 407 | : p.allowed_merge_methods.filter((other) => other !== method), | |
| 408 | }) | |
| 409 | } | |
| 410 | /> | |
| 411 | ))} | |
| 412 | </div> | |
| 413 | </div> | |
| 414 | </div> | |
| 415 | ); | |
| 416 | } | |
| 417 | case "required_status_checks": { | |
| 418 | const p = entry.parameters; | |
| 419 | const names = p.checks.map((check) => check.context); | |
| 420 | const suggestions = seen.map((check) => check.name).filter((name) => !names.some((have) => have.toLowerCase() === name.toLowerCase())); | |
| 421 | return ( | |
| 422 | <div className="space-y-3"> | |
| 423 | <div> | |
| 424 | <p className="mb-2 text-sm text-fg">Checks</p> | |
| 425 | <ul className="space-y-1.5"> | |
| 426 | {p.checks.map((check, index) => ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 427 | <Card asChild key={check.context} tone="plain" radius="lg" className="flex flex-wrap items-center gap-2 px-2.5 py-1.5"> |
| 428 | <li> | |
| 429 | <span className="grow font-mono text-[0.8125rem]">{check.context}</span> | |
| 430 | <Choice | |
| 431 | label={`Where ${check.context} comes from`} | |
| 432 | value={check.integration ?? "any"} | |
| 433 | disabled={disabled} | |
| 434 | options={[ | |
| 435 | { value: "any", label: "Any source" }, | |
| 436 | { value: "actions", label: "Workflows" }, | |
| 437 | { value: "deployments", label: "Deployments" }, | |
| 438 | { value: "security", label: "Security" }, | |
| 439 | { value: "g1t", label: "g1t" }, | |
| 440 | { value: "api", label: "The API" }, | |
| 441 | ]} | |
| 442 | onChange={(value) => | |
| 443 | set({ | |
| 444 | ...p, | |
| 445 | checks: p.checks.map((one, at) => | |
| 446 | at === index ? (value === "any" ? { context: one.context } : { context: one.context, integration: value }) : one, | |
| 447 | ), | |
| 448 | }) | |
| 449 | } | |
| 450 | /> | |
| 451 | {!disabled && ( | |
| 452 | <Button type="button" aria-label={`Stop requiring ${check.context}`} variant="ghost" size="inline" className="rounded p-1 text-faint" onClick={() => set({ ...p, checks: p.checks.filter((_, at) => at !== index) })}> | |
| 453 | <X size={14} /> | |
| 454 | </Button> | |
| 455 | )} | |
| 456 | </li> | |
| 457 | </Card> | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 458 | ))} |
| 459 | </ul> | |
| 460 | <div className="mt-2"> | |
| 461 | <PatternList | |
| 462 | label="Add a required check" | |
| 463 | values={[]} | |
| 464 | placeholder="CI" | |
| 465 | mono={false} | |
| 466 | disabled={disabled} | |
| 467 | quick={suggestions.slice(0, 8).map((name) => ({ value: name, label: name }))} | |
| 468 | onChange={(added) => set({ ...p, checks: [...p.checks, ...added.map((context) => ({ context }))] })} | |
| 469 | /> | |
| 470 | </div> | |
| 471 | </div> | |
| 472 | <div className="divide-y divide-line"> | |
| 473 | <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 })} /> | |
| 474 | <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 })} /> | |
| 475 | </div> | |
| 476 | <div> | |
| 477 | <p className="text-sm text-fg">Only when these paths change</p> | |
| 478 | <p className="mb-2 text-xs text-muted">Empty: always required.</p> | |
| 479 | <PatternList label="Paths" values={p.paths} placeholder="infra/**" disabled={disabled} onChange={(paths) => set({ ...p, paths })} /> | |
| 480 | </div> | |
| 481 | </div> | |
| 482 | ); | |
| 483 | } | |
| 484 | case "merge_queue": { | |
| 485 | const p = entry.parameters; | |
| 486 | return ( | |
| 487 | <div className="divide-y divide-line"> | |
| 488 | <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 })} /> | |
| 489 | <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 })} /> | |
| 490 | <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 })} /> | |
| 491 | <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 })} /> | |
| 492 | </div> | |
| 493 | ); | |
| 494 | } | |
| 495 | case "required_deployments": | |
| 496 | return ( | |
| 497 | <PatternList | |
| 498 | label="Environments" | |
| 499 | values={entry.parameters.environments} | |
| 500 | placeholder="preview, or a project's slug" | |
| 501 | quick={[{ value: "preview", label: "preview" }]} | |
| 502 | disabled={disabled} | |
| 503 | onChange={(environments) => set({ environments })} | |
| 504 | /> | |
| 505 | ); | |
| 506 | case "commit_message_pattern": | |
| 507 | case "commit_author_email_pattern": | |
| 508 | case "committer_email_pattern": | |
| 509 | case "branch_name_pattern": | |
| 510 | case "tag_name_pattern": { | |
| 511 | const p = entry.parameters; | |
| 512 | return ( | |
| 513 | <div className="space-y-2"> | |
| 514 | <div className="flex flex-wrap items-center gap-2"> | |
| 515 | <Choice label="How it compares" value={p.operator} options={OPERATORS} disabled={disabled} onChange={(operator) => set({ ...p, operator })} /> | |
| 516 | <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 })} /> | |
| 517 | </div> | |
| 518 | <div className="flex flex-wrap items-center gap-4"> | |
| 519 | <CheckboxOption label="Must not match" checked={p.negate} disabled={disabled} onCheckedChange={(checked) => set({ ...p, negate: checked === true })} /> | |
| 520 | <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 })} /> | |
| 521 | </div> | |
| 522 | {p.operator === "regex" && <p className="text-xs text-muted">Runs on a linear-time engine: no look-around or back-references.</p>} | |
| 523 | </div> | |
| 524 | ); | |
| 525 | } | |
| 526 | case "file_path_restriction": | |
| 527 | return ( | |
| 528 | <PatternList | |
| 529 | label="Restricted paths" | |
| 530 | values={entry.parameters.restricted_file_paths} | |
| 531 | placeholder=".g1t/workflows/**" | |
| 532 | quick={[ | |
| 533 | { value: ".g1t/workflows/**", label: "Workflows" }, | |
| 534 | { value: "CODEOWNERS", label: "CODEOWNERS" }, | |
| 535 | ]} | |
| 536 | disabled={disabled} | |
| 537 | onChange={(restricted_file_paths) => set({ restricted_file_paths })} | |
| 538 | /> | |
| 539 | ); | |
| 540 | case "file_extension_restriction": | |
| 541 | return ( | |
| 542 | <PatternList | |
| 543 | label="Restricted extensions" | |
| 544 | values={entry.parameters.restricted_file_extensions} | |
| 545 | placeholder=".exe" | |
| 546 | quick={[ | |
| 547 | { value: ".exe", label: ".exe" }, | |
| 548 | { value: ".zip", label: ".zip" }, | |
| 549 | ]} | |
| 550 | disabled={disabled} | |
| 551 | onChange={(restricted_file_extensions) => set({ restricted_file_extensions })} | |
| 552 | /> | |
| 553 | ); | |
| 554 | case "max_file_size": | |
| 555 | 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 })} />; | |
| 556 | case "max_file_path_length": | |
| 557 | 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 })} />; | |
| 558 | case "max_files_changed": | |
| 559 | 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 })} />; | |
| 560 | case "confidence_threshold": { | |
| 561 | const p = entry.parameters; | |
| 562 | return ( | |
| 563 | <div className="divide-y divide-line"> | |
| 564 | <label className="flex items-center justify-between gap-4 py-1.5"> | |
| 565 | <span className="text-sm text-fg">Changes rated below</span> | |
| 566 | <Choice label="Lowest confidence that merges without people" value={p.minimum} options={LEVELS} disabled={disabled} onChange={(minimum) => set({ ...p, minimum })} /> | |
| 567 | </label> | |
| 568 | <NumberField label="need approvals from people" min={1} max={10} value={p.required_approvals} disabled={disabled} onChange={(required_approvals) => set({ ...p, required_approvals })} /> | |
| 569 | </div> | |
| 570 | ); | |
| 571 | } | |
| 572 | case "cost_cap": | |
| 573 | 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 })} />; | |
| 574 | case "path_review": { | |
| 575 | const p = entry.parameters; | |
| 576 | return ( | |
| 577 | <div className="space-y-2"> | |
| 578 | <PatternList label="Sensitive paths" values={p.paths} placeholder="infra/**" disabled={disabled} onChange={(paths) => set({ ...p, paths })} /> | |
| 579 | <NumberField label="Approvals from people" min={1} max={10} value={p.required_approvals} disabled={disabled} onChange={(required_approvals) => set({ ...p, required_approvals })} /> | |
| 580 | <label className="flex items-center justify-between gap-4 py-1.5"> | |
| 581 | <span className="text-sm text-fg">From the team</span> | |
| 582 | <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 })} /> | |
| 583 | </label> | |
| 584 | </div> | |
| 585 | ); | |
| 586 | } | |
| 587 | case "merge_window": { | |
| 588 | const p = entry.parameters; | |
| 589 | return ( | |
| 590 | <div className="space-y-4"> | |
| 591 | <label className="flex items-center justify-between gap-4"> | |
| 592 | <span className="text-sm text-fg">Time zone, as an offset from UTC</span> | |
| 593 | <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 })} /> | |
| 594 | </label> | |
| 595 | <div> | |
| 596 | <p className="text-sm text-fg">Weekly hours when merging is open</p> | |
| 597 | <p className="mb-2 text-xs text-muted">None: open whenever no freeze covers the moment.</p> | |
| 598 | <div className="space-y-2"> | |
| 599 | {p.windows.map((window, index) => ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 600 | <Card key={index} tone="plain" radius="lg" className="flex flex-wrap items-center gap-2 p-2"> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 601 | <div className="flex flex-wrap gap-1"> |
| 602 | {DAYS.map((day) => { | |
| 603 | const on = window.days.includes(day); | |
| 604 | return ( | |
| 605 | <button | |
| 606 | key={day} | |
| 607 | type="button" | |
| 608 | aria-pressed={on} | |
| 609 | disabled={disabled} | |
| 610 | 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")} | |
| 611 | onClick={() => | |
| 612 | set({ | |
| 613 | ...p, | |
| 614 | windows: p.windows.map((one, at) => | |
| 615 | at === index ? { ...one, days: on ? one.days.filter((other) => other !== day) : DAYS.filter((d) => d === day || one.days.includes(d)) } : one, | |
| 616 | ), | |
| 617 | }) | |
| 618 | } | |
| 619 | > | |
| 620 | {DAY_LABEL[day]} | |
| 621 | </button> | |
| 622 | ); | |
| 623 | })} | |
| 624 | </div> | |
| 625 | <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)) })} /> | |
| 626 | <span className="text-sm text-muted">to</span> | |
| 627 | <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)) })} /> | |
| 628 | {!disabled && ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 629 | <Button type="button" aria-label={`Remove window ${index + 1}`} variant="ghost" size="inline" className="rounded p-1 text-faint" onClick={() => set({ ...p, windows: p.windows.filter((_, at) => at !== index) })}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 630 | <X size={14} /> |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 631 | </Button> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 632 | )} |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 633 | </Card> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 634 | ))} |
| 635 | {!disabled && ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 636 | <Button type="button" variant="outline" onClick={() => set({ ...p, windows: [...p.windows, { days: ["mon", "tue", "wed", "thu", "fri"], start: "09:00", end: "17:00" }] })}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 637 | <Plus size={14} /> Weekly hours |
| 638 | </Button> | |
| 639 | )} | |
| 640 | </div> | |
| 641 | </div> | |
| 642 | <div> | |
| 643 | <p className="mb-2 text-sm text-fg">Freezes: merging waits</p> | |
| 644 | <PeriodList label="Freeze" periods={p.freezes} allowOpen disabled={disabled} onChange={(freezes) => set({ ...p, freezes })} /> | |
| 645 | </div> | |
| 646 | <div> | |
| 647 | <p className="mb-2 text-sm text-fg">Exceptions: merging is open whatever else says</p> | |
| 648 | <PeriodList label="Exception" periods={p.exceptions} disabled={disabled} onChange={(exceptions) => set({ ...p, exceptions })} /> | |
| 649 | </div> | |
| 650 | </div> | |
| 651 | ); | |
| 652 | } | |
| 653 | case "agent_auto_merge": { | |
| 654 | const p = entry.parameters; | |
| 655 | return ( | |
| 656 | <div className="divide-y divide-line"> | |
| 657 | <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 })} /> | |
| 658 | <label className="flex items-center justify-between gap-4 py-1.5"> | |
| 659 | <span className="text-sm text-fg">Only at this confidence or higher</span> | |
| 660 | <Choice | |
| 661 | label="Lowest confidence that merges by itself" | |
| 662 | value={p.minimum_confidence ?? "any"} | |
| 663 | options={[{ value: "any", label: "Any" }, ...LEVELS]} | |
| 664 | disabled={disabled} | |
| 665 | onChange={(value) => set({ ...p, minimum_confidence: value === "any" ? null : value })} | |
| 666 | /> | |
| 667 | </label> | |
| 668 | </div> | |
| 669 | ); | |
| 670 | } | |
| 671 | default: | |
| 672 | return null; | |
| 673 | } | |
| 674 | } | |
| 675 | ||
| 676 | const APPLIES: { value: AppliesTo; label: string }[] = [ | |
| 677 | { value: "everyone", label: "Everyone" }, | |
| 678 | { value: "agents", label: "Agents' changes" }, | |
| 679 | { value: "people", label: "People's changes" }, | |
| 680 | ]; | |
| 681 | ||
| 682 | function RuleCard({ | |
| 683 | entry, | |
| 684 | onChange, | |
| 685 | onRemove, | |
| 686 | seen, | |
| 687 | disabled, | |
| 688 | }: { | |
| 689 | entry: RuleEntry; | |
| 690 | onChange: (entry: RuleEntry) => void; | |
| 691 | onRemove: () => void; | |
| 692 | seen: SeenCheck[]; | |
| 693 | disabled?: boolean; | |
| 694 | }) { | |
| 695 | const info = ruleInfo(entry.type); | |
| 696 | const agentsOnly = info?.group === "agents"; | |
| 697 | return ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 698 | <Card asChild> |
| 699 | <li> | |
| 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}`} variant="ghost" size="inline" className="p-1.5 text-faint hover:bg-bg hover:text-danger" onClick={onRemove}> | |
| 712 | <Trash2 size={15} /> | |
| 713 | </Button> | |
| 714 | )} | |
| 715 | </div> | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 716 | </div> |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 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 | </Card> | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 724 | ); |
| 725 | } | |
| 726 | ||
| 727 | // --- Bypass list -------------------------------------------------------------- | |
| 728 | ||
| 729 | const KINDS: { value: BypassActorKind; label: string; placeholder: string }[] = [ | |
| 730 | { value: "role", label: "Role", placeholder: "admin" }, | |
| 731 | { value: "team", label: "Team", placeholder: "workspace/team" }, | |
| 732 | { value: "user", label: "Person", placeholder: "username" }, | |
| 733 | { value: "token", label: "Token", placeholder: "token id, or workspace" }, | |
| 734 | { value: "g1t", label: "g1t", placeholder: "" }, | |
| 735 | ]; | |
| 736 | ||
| 737 | function BypassList({ actors, onChange, disabled }: { actors: BypassActor[]; onChange: (actors: BypassActor[]) => void; disabled?: boolean }) { | |
| 738 | const update = (index: number, change: Partial<BypassActor>) => onChange(actors.map((one, at) => (at === index ? { ...one, ...change } : one))); | |
| 739 | const hasG1t = actors.some((actor) => actor.kind === "g1t"); | |
| 740 | return ( | |
| 741 | <div className="space-y-2"> | |
| 742 | {actors.length === 0 && <p className="text-sm text-muted">Nobody: these rules hold for everyone, people and agents alike, g1t included.</p>} | |
| 743 | <ul className="space-y-2"> | |
| 744 | {actors.map((actor, index) => ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 745 | <Card asChild key={index} tone="plain" radius="lg" className="flex flex-wrap items-center gap-2 p-2"> |
| 746 | <li> | |
| 747 | <Choice label="Who" value={actor.kind} options={KINDS} disabled={disabled} onChange={(kind) => update(index, { kind, value: kind === "role" ? "admin" : "" })} /> | |
| 748 | {actor.kind === "role" ? ( | |
| 749 | <Choice | |
| 750 | label="Role" | |
| 751 | value={actor.value || "admin"} | |
| 752 | disabled={disabled} | |
| 753 | options={[ | |
| 754 | { value: "write", label: "Write and up" }, | |
| 755 | { value: "maintain", label: "Maintain and up" }, | |
| 756 | { value: "admin", label: "Admin" }, | |
| 757 | { value: "owner", label: "Workspace owners" }, | |
| 758 | ]} | |
| 759 | onChange={(value) => update(index, { value })} | |
| 760 | /> | |
| 761 | ) : actor.kind === "g1t" ? ( | |
| 762 | <span className="grow text-sm text-muted">g1t's agents and g1t itself (the merge queue, security updates)</span> | |
| 763 | ) : ( | |
| 764 | <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 })} /> | |
| 765 | )} | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 766 | <Choice |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 767 | label="When" |
| 768 | value={actor.mode} | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 769 | disabled={disabled} |
| 770 | options={[ | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 771 | { value: "always", label: "Always" }, |
| 772 | { value: "pull_requests", label: "Pull requests only" }, | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 773 | ]} |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 774 | onChange={(mode) => update(index, { mode })} |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 775 | /> |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 776 | {!disabled && ( |
| 777 | <Button type="button" aria-label={`Remove ${describeBypassActor(actor)}`} variant="ghost" size="inline" className="rounded p-1 text-faint" onClick={() => onChange(actors.filter((_, at) => at !== index))}> | |
| 778 | <X size={14} /> | |
| 779 | </Button> | |
| 780 | )} | |
| 781 | </li> | |
| 782 | </Card> | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 783 | ))} |
| 784 | </ul> | |
| 785 | {hasG1t && ( | |
| 786 | <p className="flex items-start gap-2 text-xs text-warn"> | |
| 787 | <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. | |
| 788 | </p> | |
| 789 | )} | |
| 790 | {!disabled && ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 791 | <Button type="button" variant="outline" onClick={() => onChange([...actors, { kind: "role", value: "admin", mode: "pull_requests" }])}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 792 | <Plus size={14} /> Add a bypass |
| 793 | </Button> | |
| 794 | )} | |
| 795 | </div> | |
| 796 | ); | |
| 797 | } | |
| 798 | ||
| 799 | // --- The form ----------------------------------------------------------------- | |
| 800 | ||
| 801 | function Block({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) { | |
| 802 | return ( | |
| 803 | <section className="grid gap-x-10 gap-y-3 border-t border-line pt-6 lg:grid-cols-[14rem_1fr]"> | |
| 804 | <div> | |
| 805 | <h2 className="font-medium">{title}</h2> | |
| 806 | <p className="mt-1 text-sm text-muted">{about}</p> | |
| 807 | </div> | |
| 808 | <div className="min-w-0 space-y-3">{children}</div> | |
| 809 | </section> | |
| 810 | ); | |
| 811 | } | |
| 812 | ||
| 813 | /** Downloads `ruleset` as a JSON file. */ | |
| 814 | function download(ruleset: RulesetSpec) { | |
| 815 | const blob = new Blob([JSON.stringify(exportRuleset(ruleset), null, 2) + "\n"], { type: "application/json" }); | |
| 816 | const url = URL.createObjectURL(blob); | |
| 817 | const link = document.createElement("a"); | |
| 818 | link.href = url; | |
| 819 | link.download = `${(ruleset.name || "ruleset").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "ruleset"}.json`; | |
| 820 | link.click(); | |
| 821 | URL.revokeObjectURL(url); | |
| 822 | } | |
| 823 | ||
| 824 | /** | |
| 825 | * Creating or changing a ruleset. The ruleset is posted whole, as JSON in | |
| 826 | * `ruleset`, with `intent` `save`; deleting posts `intent` `delete`. | |
| 827 | */ | |
| 828 | export function RulesetForm({ | |
| 829 | initial, | |
| 830 | level, | |
| 831 | existing, | |
| 832 | seen = [], | |
| 833 | editable, | |
| 834 | error, | |
| 835 | backHref, | |
| 836 | repositoryLabel, | |
| 837 | }: { | |
| 838 | initial: RulesetSpec; | |
| 839 | level: Level; | |
| 840 | /** The ruleset being changed; absent for a new one. */ | |
| 841 | existing?: Ruleset; | |
| 842 | /** Check names reported lately, to require. */ | |
| 843 | seen?: SeenCheck[]; | |
| 844 | editable: boolean; | |
| 845 | error?: string | null; | |
| 846 | backHref: string; | |
| 847 | /** For a repository's: its name, for the hint about the default branch. */ | |
| 848 | repositoryLabel?: string; | |
| 849 | }) { | |
| 850 | const [spec, setSpec] = useState<RulesetSpec>(initial); | |
| 851 | const [imported, setImported] = useState<string | null>(null); | |
| 852 | const file = useRef<HTMLInputElement>(null); | |
| 853 | const disabled = !editable; | |
| 854 | const update = (change: Partial<RulesetSpec>) => setSpec((current) => ({ ...current, ...change })); | |
| 855 | const addable = RULES.filter( | |
| 856 | (info) => info.targets.includes(spec.target) && (info.repeatable || !spec.rules.some((rule) => rule.type === info.type && rule.applies_to === "everyone")), | |
| 857 | ); | |
| 858 | const fromBranchProtection = existing?.source === "branch_protection"; | |
| 859 | return ( | |
| 860 | <> | |
| 861 | <Form method="post" className="space-y-6"> | |
| 862 | <input type="hidden" name="intent" value="save" /> | |
| 863 | <input type="hidden" name="ruleset" value={JSON.stringify(spec)} /> | |
| 864 | {fromBranchProtection && ( | |
| 865 | <p className="flex items-start gap-2 rounded-lg border border-info/30 bg-info/5 p-3 text-sm text-muted"> | |
| 866 | <ShieldCheck size={15} className="mt-0.5 shrink-0 text-info" /> | |
| 867 | Made from this repository's branch protection settings, holding exactly what they held. The pull request, status check and merge | |
| 868 | queue rules here are also what Settings → Branches and the settings API read and write. | |
| 869 | </p> | |
| 870 | )} | |
| 871 | <section className="grid gap-4 md:grid-cols-[1fr_auto]"> | |
| 872 | <label className="block"> | |
| 873 | <span className="mb-1.5 block text-sm font-medium text-muted">Name</span> | |
| 874 | <Input name="name" value={spec.name} disabled={disabled} maxLength={100} placeholder="Protect main" required onChange={(event) => update({ name: event.target.value })} /> | |
| 875 | </label> | |
| 876 | <div> | |
| 877 | <span className="mb-1.5 block text-sm font-medium text-muted">Targets</span> | |
| 878 | <div className="flex rounded-md border border-line p-0.5" role="radiogroup" aria-label="Targets"> | |
| 879 | {(["branch", "tag"] as const).map((target) => ( | |
| 880 | <button | |
| 881 | key={target} | |
| 882 | type="button" | |
| 883 | role="radio" | |
| 884 | aria-checked={spec.target === target} | |
| 885 | disabled={disabled} | |
| 886 | 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")} | |
| 887 | onClick={() => | |
| 888 | update({ | |
| 889 | target, | |
| 890 | conditions: { ...spec.conditions, ref_name: { include: target === "tag" ? [] : [DEFAULT_BRANCH], exclude: [] } }, | |
| 891 | rules: spec.rules.filter((rule) => ruleInfo(rule.type)?.targets.includes(target)), | |
| 892 | }) | |
| 893 | } | |
| 894 | > | |
| 895 | {target === "branch" ? <GitBranch size={14} /> : <Tag size={14} />} | |
| 896 | {target === "branch" ? "Branches" : "Tags"} | |
| 897 | </button> | |
| 898 | ))} | |
| 899 | </div> | |
| 900 | </div> | |
| 901 | </section> | |
| 902 | ||
| 903 | <Block title="Enforcement" about="Try a ruleset in evaluate first: Insights shows what it would have refused."> | |
| 904 | <RadioGroup value={spec.enforcement} onValueChange={(value) => update({ enforcement: value as Enforcement })} disabled={disabled} className="grid gap-3 sm:grid-cols-3"> | |
| 905 | {(Object.keys(ENFORCEMENT) as Enforcement[]).map((enforcement) => ( | |
| 906 | <RadioOption key={enforcement} value={enforcement} label={ENFORCEMENT[enforcement].label} description={ENFORCEMENT[enforcement].about} /> | |
| 907 | ))} | |
| 908 | </RadioGroup> | |
| 909 | </Block> | |
| 910 | ||
| 911 | <Block | |
| 912 | title={spec.target === "branch" ? "Branches" : "Tags"} | |
| 913 | about={ | |
| 914 | <> | |
| 915 | Names it holds for: fnmatch patterns, <code>*</code> within a segment and <code>**</code> across them. | |
| 916 | {repositoryLabel ? ` Default branch follows ${repositoryLabel}'s default branch if it is renamed.` : ""} | |
| 917 | </> | |
| 918 | } | |
| 919 | > | |
| 920 | <div> | |
| 921 | <p className="mb-2 text-sm text-fg">Include</p> | |
| 922 | <PatternList | |
| 923 | label="Include" | |
| 924 | values={spec.conditions.ref_name.include} | |
| 925 | placeholder={spec.target === "branch" ? "release/*" : "v*"} | |
| 926 | quick={ | |
| 927 | spec.target === "branch" | |
| 928 | ? [ | |
| 929 | { value: DEFAULT_BRANCH, label: "Default branch" }, | |
| 930 | { value: ALL, label: "All branches" }, | |
| 931 | ] | |
| 932 | : [{ value: ALL, label: "All tags" }] | |
| 933 | } | |
| 934 | disabled={disabled} | |
| 935 | onChange={(include) => update({ conditions: { ...spec.conditions, ref_name: { ...spec.conditions.ref_name, include } } })} | |
| 936 | /> | |
| 937 | </div> | |
| 938 | <div> | |
| 939 | <p className="mb-2 text-sm text-fg">Exclude</p> | |
| 940 | <PatternList | |
| 941 | label="Exclude" | |
| 942 | values={spec.conditions.ref_name.exclude} | |
| 943 | placeholder={spec.target === "branch" ? "dependabot/**" : "v*-rc*"} | |
| 944 | disabled={disabled} | |
| 945 | onChange={(exclude) => update({ conditions: { ...spec.conditions, ref_name: { ...spec.conditions.ref_name, exclude } } })} | |
| 946 | /> | |
| 947 | </div> | |
| 948 | </Block> | |
| 949 | ||
| 950 | {level === "workspace" && spec.conditions.repository && ( | |
| 951 | <Block title="Repositories" about="Which of the workspace's repositories it holds in."> | |
| 952 | <div> | |
| 953 | <p className="mb-2 text-sm text-fg">Names to include</p> | |
| 954 | <PatternList | |
| 955 | label="Repositories to include" | |
| 956 | values={spec.conditions.repository.include} | |
| 957 | placeholder="api-*" | |
| 958 | quick={[{ value: ALL, label: "All repositories" }]} | |
| 959 | disabled={disabled} | |
| 960 | onChange={(include) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, include } } })} | |
| 961 | /> | |
| 962 | </div> | |
| 963 | <div> | |
| 964 | <p className="mb-2 text-sm text-fg">Names to exclude</p> | |
| 965 | <PatternList | |
| 966 | label="Repositories to exclude" | |
| 967 | values={spec.conditions.repository.exclude} | |
| 968 | placeholder="sandbox-*" | |
| 969 | disabled={disabled} | |
| 970 | onChange={(exclude) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, exclude } } })} | |
| 971 | /> | |
| 972 | </div> | |
| 973 | <label className="flex items-center justify-between gap-4"> | |
| 974 | <span className="text-sm text-fg">Visibility</span> | |
| 975 | <Choice | |
| 976 | label="Visibility" | |
| 977 | value={spec.conditions.repository.visibility} | |
| 978 | disabled={disabled} | |
| 979 | options={[ | |
| 980 | { value: "any", label: "Public and private" }, | |
| 981 | { value: "public", label: "Public only" }, | |
| 982 | { value: "private", label: "Private only" }, | |
| 983 | ]} | |
| 984 | onChange={(visibility) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, visibility } } })} | |
| 985 | /> | |
| 986 | </label> | |
| 987 | <div> | |
| 988 | <p className="text-sm text-fg">Topics</p> | |
| 989 | <p className="mb-2 text-xs text-muted">Only repositories carrying one of these. Empty: any.</p> | |
| 990 | <PatternList | |
| 991 | label="Topics" | |
| 992 | values={spec.conditions.repository.topics} | |
| 993 | placeholder="payments" | |
| 994 | mono={false} | |
| 995 | disabled={disabled} | |
| 996 | onChange={(topics) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, topics } } })} | |
| 997 | /> | |
| 998 | </div> | |
| 999 | </Block> | |
| 1000 | )} | |
| 1001 | ||
| 1002 | <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."> | |
| 1003 | <BypassList actors={spec.bypass_actors} disabled={disabled} onChange={(bypass_actors) => update({ bypass_actors })} /> | |
| 1004 | </Block> | |
| 1005 | ||
| 1006 | <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."> | |
| 1007 | {spec.rules.length === 0 && <p className="text-sm text-muted">No rules yet.</p>} | |
| 1008 | <ul className="space-y-3"> | |
| 1009 | {spec.rules.map((entry, index) => ( | |
| 1010 | <RuleCard | |
| 1011 | key={`${entry.type}-${index}`} | |
| 1012 | entry={entry} | |
| 1013 | seen={seen} | |
| 1014 | disabled={disabled} | |
| 1015 | onChange={(changed) => update({ rules: spec.rules.map((one, at) => (at === index ? changed : one)) })} | |
| 1016 | onRemove={() => update({ rules: spec.rules.filter((_, at) => at !== index) })} | |
| 1017 | /> | |
| 1018 | ))} | |
| 1019 | </ul> | |
| 1020 | {editable && ( | |
| 1021 | <DropdownMenu> | |
| 1022 | <DropdownMenuTrigger asChild> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1023 | <Button type="button" variant="outline"> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1024 | <Plus size={14} /> Add a rule |
| 1025 | </Button> | |
| 1026 | </DropdownMenuTrigger> | |
| 1027 | <DropdownMenuContent align="start" className="max-h-[60vh] w-80 overflow-y-auto"> | |
| 1028 | {RULE_GROUPS.map((group, groupIndex) => { | |
| 1029 | const rules = addable.filter((info) => info.group === group.id); | |
| 1030 | if (rules.length === 0) return null; | |
| 1031 | return ( | |
| 1032 | <div key={group.id}> | |
| 1033 | {groupIndex > 0 && <DropdownMenuSeparator />} | |
| 1034 | <DropdownMenuLabel>{group.label}</DropdownMenuLabel> | |
| 1035 | {rules.map((info) => ( | |
| 1036 | <DropdownMenuItem key={info.type} onSelect={() => update({ rules: [...spec.rules, newRule(info.type as RuleType)] })}> | |
| 1037 | <span className="min-w-0"> | |
| 1038 | <span className="block text-sm">{info.label}</span> | |
| 1039 | <span className="block text-xs text-muted">{info.about}</span> | |
| 1040 | </span> | |
| 1041 | </DropdownMenuItem> | |
| 1042 | ))} | |
| 1043 | </div> | |
| 1044 | ); | |
| 1045 | })} | |
| 1046 | </DropdownMenuContent> | |
| 1047 | </DropdownMenu> | |
| 1048 | )} | |
| 1049 | </Block> | |
| 1050 | ||
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 1051 | <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 -mx-4 flex flex-wrap items-center gap-3 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1052 | {editable && ( |
| 1053 | <SubmitButton pending="Saving…" match={{ intent: "save" }}> | |
| 1054 | {existing ? "Save changes" : "Create ruleset"} | |
| 1055 | </SubmitButton> | |
| 1056 | )} | |
| 1057 | <Link to={backHref} className="text-sm text-muted hover:text-fg"> | |
| 1058 | {editable ? "Cancel" : "Back to rules"} | |
| 1059 | </Link> | |
| 1060 | <span className="grow" /> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1061 | <Button type="button" variant="outline" onClick={() => download(spec)}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1062 | <Download size={14} /> Export JSON |
| 1063 | </Button> | |
| 1064 | {editable && ( | |
| 1065 | <> | |
| 1066 | <input | |
| 1067 | ref={file} | |
| 1068 | type="file" | |
| 1069 | accept="application/json,.json" | |
| 1070 | className="hidden" | |
| 1071 | onChange={async (event) => { | |
| 1072 | const chosen = event.target.files?.[0]; | |
| 1073 | event.target.value = ""; | |
| 1074 | if (!chosen) return; | |
| 1075 | try { | |
| 1076 | setSpec(importRuleset(await chosen.text(), level)); | |
| 1077 | setImported(`Imported ${chosen.name}. Check it, then save.`); | |
| 1078 | } catch (problem) { | |
| 1079 | setImported(problem instanceof Error ? problem.message : "That file could not be read."); | |
| 1080 | } | |
| 1081 | }} | |
| 1082 | /> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1083 | <Button type="button" variant="outline" onClick={() => file.current?.click()}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1084 | <FileUp size={14} /> Import JSON |
| 1085 | </Button> | |
| 1086 | </> | |
| 1087 | )} | |
| 1088 | {imported && <p className="w-full text-sm text-muted">{imported}</p>} | |
| 1089 | <div className="w-full"> | |
| 1090 | <ErrorText>{error}</ErrorText> | |
| 1091 | </div> | |
| 1092 | </div> | |
| 1093 | </Form> | |
| 1094 | {editable && existing && ( | |
| 1095 | <Form method="post" className="mt-8 rounded-xl border border-danger/30 p-4"> | |
| 1096 | <input type="hidden" name="intent" value="delete" /> | |
| 1097 | <h2 className="font-medium">Delete this ruleset</h2> | |
| 1098 | <p className="mt-1 text-sm text-muted">Its rules stop holding at once. Its evaluations stay in Insights.</p> | |
| 1099 | <div className="mt-3"> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1100 | <SubmitButton variant="destructive" pending="Deleting…" match={{ intent: "delete" }}> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1101 | <Trash2 size={14} /> Delete ruleset |
| 1102 | </SubmitButton> | |
| 1103 | </div> | |
| 1104 | </Form> | |
| 1105 | )} | |
| 1106 | </> | |
| 1107 | ); | |
| 1108 | } | |
| 1109 | ||
| 1110 | // --- The list ----------------------------------------------------------------- | |
| 1111 | ||
| 1112 | function RulesetRow({ ruleset, href, inherited }: { ruleset: Ruleset; href: string; inherited?: boolean }) { | |
| 1113 | const agents = ruleset.rules.some((rule) => rule.applies_to === "agents" || ruleInfo(rule.type)?.group === "agents"); | |
| 1114 | return ( | |
| 1115 | <li> | |
| 1116 | <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"> | |
| 1117 | <span className="mt-0.5 shrink-0 text-muted"> | |
| 1118 | {ruleset.enforcement === "disabled" ? <ShieldOff size={16} /> : <ShieldCheck size={16} className={ruleset.enforcement === "active" ? "text-accent" : "text-info"} />} | |
| 1119 | </span> | |
| 1120 | <span className="min-w-0 grow basis-48"> | |
| 1121 | <span className="flex flex-wrap items-center gap-2"> | |
| 1122 | <span className="font-medium group-hover:underline">{ruleset.name}</span> | |
| 1123 | <EnforcementBadge enforcement={ruleset.enforcement} /> | |
| 1124 | {inherited && <Badge>From the workspace</Badge>} | |
| 1125 | {ruleset.source === "branch_protection" && <Badge>Branch protection</Badge>} | |
| 1126 | {agents && ( | |
| 1127 | <Badge tone="merged"> | |
| 1128 | <Bot size={11} /> Agent rules | |
| 1129 | </Badge> | |
| 1130 | )} | |
| 1131 | </span> | |
| 1132 | <span className="mt-1 block text-sm text-muted"> | |
| 1133 | {ruleset.target === "tag" ? "Tags" : "Branches"}: {targetSummary(ruleset)} · {ruleset.rules.length} {ruleset.rules.length === 1 ? "rule" : "rules"} | |
| 1134 | {ruleset.bypass_actors.length > 0 && ` · ${ruleset.bypass_actors.length} may bypass`} | |
| 1135 | </span> | |
| 1136 | </span> | |
| 1137 | <span className="shrink-0 text-xs text-faint"> | |
| 1138 | Changed <TimeAgo at={ruleset.updated_at} /> by <span className="font-mono">{ruleset.updated_by}</span> | |
| 1139 | </span> | |
| 1140 | </Link> | |
| 1141 | </li> | |
| 1142 | ); | |
| 1143 | } | |
| 1144 | ||
| 1145 | export function RulesetList({ | |
| 1146 | rulesets, | |
| 1147 | hrefFor, | |
| 1148 | level, | |
| 1149 | empty, | |
| 1150 | }: { | |
| 1151 | rulesets: Ruleset[]; | |
| 1152 | hrefFor: (ruleset: Ruleset) => string; | |
| 1153 | level: Level; | |
| 1154 | empty: ReactNode; | |
| 1155 | }) { | |
| 1156 | if (rulesets.length === 0) { | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1157 | return <Card tone="plain" className="border-dashed p-8 text-center text-sm text-muted">{empty}</Card>; |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1158 | } |
| 1159 | return ( | |
| 1160 | <ul className="space-y-2"> | |
| 1161 | {rulesets.map((ruleset) => ( | |
| 1162 | <RulesetRow key={ruleset.id} ruleset={ruleset} href={hrefFor(ruleset)} inherited={level === "repository" && ruleset.level === "workspace"} /> | |
| 1163 | ))} | |
| 1164 | </ul> | |
| 1165 | ); | |
| 1166 | } | |
| 1167 | ||
| 1168 | // --- Effective rules ---------------------------------------------------------- | |
| 1169 | ||
| 1170 | /** Every rule that holds for one branch, grouped by where it comes from. */ | |
| 1171 | export function EffectiveRulesView({ effective, hrefFor }: { effective: EffectiveRules; hrefFor: (id: string, level: Level) => string }) { | |
| 1172 | if (effective.rules.length === 0) { | |
| 1173 | return ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1174 | <Card asChild tone="plain" className="border-dashed p-6 text-sm text-muted"> |
| 1175 | <p> | |
| 1176 | No ruleset targets <span className="font-mono text-fg">{effective.name}</span>: anyone who may push can change it however they like. | |
| 1177 | </p> | |
| 1178 | </Card> | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1179 | ); |
| 1180 | } | |
| 1181 | return ( | |
| 1182 | <div className="space-y-3"> | |
| 1183 | {effective.rulesets.map((ruleset) => { | |
| 1184 | const rules = effective.rules.filter((rule) => rule.ruleset_id === ruleset.id); | |
| 1185 | return ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1186 | <Card key={ruleset.id} tone="plain"> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1187 | <div className="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2.5"> |
| 1188 | <Link to={hrefFor(ruleset.id, ruleset.level)} className="font-medium hover:underline"> | |
| 1189 | {ruleset.name} | |
| 1190 | </Link> | |
| 1191 | <EnforcementBadge enforcement={ruleset.enforcement} /> | |
| 1192 | {ruleset.level === "workspace" && <Badge>Workspace</Badge>} | |
| 1193 | <span className="grow" /> | |
| 1194 | <span className="flex items-center gap-1 text-xs text-muted"> | |
| 1195 | <Users size={12} /> | |
| 1196 | {ruleset.bypass_actors.length === 0 ? "Nobody bypasses" : ruleset.bypass_actors.map(describeBypassActor).join(", ")} | |
| 1197 | </span> | |
| 1198 | </div> | |
| 1199 | <ul className="divide-y divide-line"> | |
| 1200 | {rules.map((rule, index) => ( | |
| 1201 | <li key={index} className="flex flex-wrap items-center gap-2 px-4 py-2 text-sm"> | |
| 1202 | <span className="grow">{ruleInfo(rule.type)?.label ?? rule.type}</span> | |
| 1203 | {rule.applies_to !== "everyone" && <Badge tone="merged">{describeAppliesTo(rule.applies_to)}</Badge>} | |
| 1204 | </li> | |
| 1205 | ))} | |
| 1206 | </ul> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1207 | </Card> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1208 | ); |
| 1209 | })} | |
| 1210 | </div> | |
| 1211 | ); | |
| 1212 | } | |
| 1213 | ||
| 1214 | // --- Insights ----------------------------------------------------------------- | |
| 1215 | ||
| 1216 | function Stat({ label, value, tone }: { label: string; value: number; tone?: "danger" | "info" | "warn" }) { | |
| 1217 | return ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1218 | <Card tone="plain" className="p-3"> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1219 | <p className="text-xs text-muted">{label}</p> |
| 1220 | <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> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1221 | </Card> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1222 | ); |
| 1223 | } | |
| 1224 | ||
| 1225 | const ACTION_LABEL: Record<string, string> = { | |
| 1226 | push: "Push", | |
| 1227 | merge: "Merge", | |
| 1228 | create_ref: "Create", | |
| 1229 | delete_ref: "Delete", | |
| 1230 | rename_ref: "Rename", | |
| 1231 | commit: "Commit", | |
| 1232 | }; | |
| 1233 | ||
| 1234 | function verdictBadge(evaluation: Evaluation) { | |
| 1235 | if (evaluation.verdict === "pass") return <Badge tone="accent">Passed</Badge>; | |
| 1236 | if (evaluation.verdict === "bypass") return <Badge tone="warn">Bypassed</Badge>; | |
| 1237 | if (evaluation.enforcement === "evaluate") return <Badge tone="info">Would block</Badge>; | |
| 1238 | return <Badge tone="danger">Blocked</Badge>; | |
| 1239 | } | |
| 1240 | ||
| 1241 | /** How the rules judged pushes and merges: the last 30 days, and each evaluation. */ | |
| 1242 | export function InsightsView({ page, showRepository, olderHref }: { page: EvaluationPage; showRepository?: boolean; olderHref?: string | null }) { | |
| 1243 | const insights = page.insights; | |
| 1244 | return ( | |
| 1245 | <div className="space-y-6"> | |
| 1246 | <div className="grid grid-cols-2 gap-2 sm:grid-cols-5"> | |
| 1247 | <Stat label={`Evaluations, ${insights.days} days`} value={insights.total} /> | |
| 1248 | <Stat label="Passed" value={insights.passed} /> | |
| 1249 | <Stat label="Blocked" value={insights.blocked} tone="danger" /> | |
| 1250 | <Stat label="Would block" value={insights.would_block} tone="info" /> | |
| 1251 | <Stat label="Bypassed" value={insights.bypassed} tone="warn" /> | |
| 1252 | </div> | |
| 1253 | {insights.by_rule.length > 0 && ( | |
| 1254 | <div> | |
| 1255 | <h3 className="mb-2 text-sm font-medium">Rules broken most</h3> | |
| 1256 | <ul className="space-y-1.5"> | |
| 1257 | {insights.by_rule.slice(0, 6).map((row) => { | |
| 1258 | const top = insights.by_rule[0]!.count || 1; | |
| 1259 | return ( | |
| 1260 | <li key={row.rule} className="flex items-center gap-3 text-sm"> | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 1261 | <span className="w-32 shrink-0 truncate sm:w-56">{ruleInfo(row.rule)?.label ?? row.rule}</span> |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1262 | <span className="h-2 grow overflow-hidden rounded-full bg-surface"> |
| 1263 | <span className="block h-full rounded-full bg-danger/70" style={{ width: `${Math.max(4, (row.count / top) * 100)}%` }} /> | |
| 1264 | </span> | |
| 1265 | <span className="w-10 shrink-0 text-right tabular-nums text-muted">{row.count}</span> | |
| 1266 | </li> | |
| 1267 | ); | |
| 1268 | })} | |
| 1269 | </ul> | |
| 1270 | </div> | |
| 1271 | )} | |
| 1272 | <div> | |
| 1273 | <h3 className="mb-2 text-sm font-medium">Recent evaluations</h3> | |
| 1274 | {page.evaluations.length === 0 ? ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1275 | <Card asChild tone="plain" className="border-dashed p-6 text-sm text-muted"> |
| 1276 | <p>Nothing evaluated yet. Pushes and merges show here as the rules judge them.</p> | |
| 1277 | </Card> | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1278 | ) : ( |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1279 | <Card asChild tone="plain" divided> |
| 1280 | <ul> | |
| 1281 | {page.evaluations.map((evaluation) => ( | |
| 1282 | <li key={evaluation.id} className="px-4 py-3"> | |
| 1283 | <div className="flex flex-wrap items-center gap-2 text-sm"> | |
| 1284 | {verdictBadge(evaluation)} | |
| 1285 | <span className="font-medium">{ACTION_LABEL[evaluation.action] ?? evaluation.action}</span> | |
| 1286 | <span className="font-mono text-[0.8125rem] text-muted">{evaluation.git_ref.replace(/^refs\/(heads|tags)\//, "")}</span> | |
| 1287 | {evaluation.number != null && <span className="text-muted">#{evaluation.number}</span>} | |
| 1288 | <span className="text-muted"> | |
| 1289 | by <span className="font-mono">{evaluation.actor}</span> | |
| 1290 | {evaluation.actor_kind !== "person" && <Bot size={12} className="ml-1 inline text-accent" />} | |
| 1291 | </span> | |
| 1292 | {showRepository && evaluation.repository && <span className="text-faint">{evaluation.repository}</span>} | |
| 1293 | <span className="grow" /> | |
| 1294 | <span className="text-xs text-faint"> | |
| 1295 | {evaluation.ruleset_name} · <TimeAgo at={evaluation.created_at} /> | |
| 1296 | </span> | |
| 1297 | </div> | |
| 1298 | {evaluation.violations.length > 0 && ( | |
| 1299 | <ul className="mt-1.5 space-y-0.5"> | |
| 1300 | {evaluation.violations.map((violation, index) => ( | |
| 1301 | <li key={index} className="text-sm text-muted"> | |
| 1302 | {violation.message} | |
| 1303 | </li> | |
| 1304 | ))} | |
| 1305 | </ul> | |
| 1306 | )} | |
| 1307 | </li> | |
| 1308 | ))} | |
| 1309 | </ul> | |
| 1310 | </Card> | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1311 | )} |
| 1312 | {olderHref && ( | |
| 1313 | <Link to={olderHref} className="mt-3 inline-block text-sm text-muted hover:text-fg"> | |
| 1314 | Older evaluations → | |
| 1315 | </Link> | |
| 1316 | )} | |
| 1317 | </div> | |
| 1318 | </div> | |
| 1319 | ); | |
| 1320 | } | |
| 1321 | ||
| 1322 | // --- The merge box ------------------------------------------------------------ | |
| 1323 | ||
| 1324 | /** Rules a pull request does not meet, each with its ruleset and how to meet it. */ | |
| 1325 | export function ViolationList({ violations, tone = "danger" }: { violations: Violation[]; tone?: "danger" | "info" | "warn" }) { | |
| 1326 | const grouped = useMemo(() => { | |
| 1327 | const seen = new Set<string>(); | |
| 1328 | return violations.filter((violation) => { | |
| 1329 | const key = `${violation.rule}:${violation.message}`; | |
| 1330 | if (seen.has(key)) return false; | |
| 1331 | seen.add(key); | |
| 1332 | return true; | |
| 1333 | }); | |
| 1334 | }, [violations]); | |
| 1335 | return ( | |
| 1336 | <ul className="space-y-2"> | |
| 1337 | {grouped.map((violation, index) => ( | |
| 1338 | <li key={index} className="flex items-start gap-2 text-sm"> | |
| 1339 | <ShieldAlert size={14} className={cn("mt-0.5 shrink-0", tone === "danger" ? "text-danger" : tone === "info" ? "text-info" : "text-warn")} /> | |
| 1340 | <span className="min-w-0"> | |
| 1341 | <span className="text-fg">{violation.message}</span>{" "} | |
| 1342 | {violation.remedy && <span className="text-muted">{violation.remedy}</span>} | |
| 1343 | <span className="mt-0.5 block text-xs text-faint"> | |
| 1344 | {ruleInfo(violation.rule)?.label ?? violation.rule} · {violation.ruleset_name} | |
| 1345 | </span> | |
| 1346 | </span> | |
| 1347 | </li> | |
| 1348 | ))} | |
| 1349 | </ul> | |
| 1350 | ); | |
| 1351 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.