g1t/apps/web/app/components/rules.tsx
| 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 | { value: "api", label: "The API" }, |
| 444 | ]} |
| 445 | onChange={(value) => |
| 446 | set({ |
| 447 | ...p, |
| 448 | checks: p.checks.map((one, at) => |
| 449 | at === index ? (value === "any" ? { context: one.context } : { context: one.context, integration: value }) : one, |
| 450 | ), |
| 451 | }) |
| 452 | } |
| 453 | /> |
| 454 | {!disabled && ( |
| 455 | <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) })}> |
| 456 | <X size={14} /> |
| 457 | </button> |
| 458 | )} |
| 459 | </li> |
| 460 | ))} |
| 461 | </ul> |
| 462 | <div className="mt-2"> |
| 463 | <PatternList |
| 464 | label="Add a required check" |
| 465 | values={[]} |
| 466 | placeholder="CI" |
| 467 | mono={false} |
| 468 | disabled={disabled} |
| 469 | quick={suggestions.slice(0, 8).map((name) => ({ value: name, label: name }))} |
| 470 | onChange={(added) => set({ ...p, checks: [...p.checks, ...added.map((context) => ({ context }))] })} |
| 471 | /> |
| 472 | </div> |
| 473 | </div> |
| 474 | <div className="divide-y divide-line"> |
| 475 | <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 })} /> |
| 476 | <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 })} /> |
| 477 | </div> |
| 478 | <div> |
| 479 | <p className="text-sm text-fg">Only when these paths change</p> |
| 480 | <p className="mb-2 text-xs text-muted">Empty: always required.</p> |
| 481 | <PatternList label="Paths" values={p.paths} placeholder="infra/**" disabled={disabled} onChange={(paths) => set({ ...p, paths })} /> |
| 482 | </div> |
| 483 | </div> |
| 484 | ); |
| 485 | } |
| 486 | case "merge_queue": { |
| 487 | const p = entry.parameters; |
| 488 | return ( |
| 489 | <div className="divide-y divide-line"> |
| 490 | <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 })} /> |
| 491 | <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 })} /> |
| 492 | <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 })} /> |
| 493 | <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 })} /> |
| 494 | </div> |
| 495 | ); |
| 496 | } |
| 497 | case "required_deployments": |
| 498 | return ( |
| 499 | <PatternList |
| 500 | label="Environments" |
| 501 | values={entry.parameters.environments} |
| 502 | placeholder="preview, or a project's slug" |
| 503 | quick={[{ value: "preview", label: "preview" }]} |
| 504 | disabled={disabled} |
| 505 | onChange={(environments) => set({ environments })} |
| 506 | /> |
| 507 | ); |
| 508 | case "commit_message_pattern": |
| 509 | case "commit_author_email_pattern": |
| 510 | case "committer_email_pattern": |
| 511 | case "branch_name_pattern": |
| 512 | case "tag_name_pattern": { |
| 513 | const p = entry.parameters; |
| 514 | return ( |
| 515 | <div className="space-y-2"> |
| 516 | <div className="flex flex-wrap items-center gap-2"> |
| 517 | <Choice label="How it compares" value={p.operator} options={OPERATORS} disabled={disabled} onChange={(operator) => set({ ...p, operator })} /> |
| 518 | <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 })} /> |
| 519 | </div> |
| 520 | <div className="flex flex-wrap items-center gap-4"> |
| 521 | <CheckboxOption label="Must not match" checked={p.negate} disabled={disabled} onCheckedChange={(checked) => set({ ...p, negate: checked === true })} /> |
| 522 | <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 })} /> |
| 523 | </div> |
| 524 | {p.operator === "regex" && <p className="text-xs text-muted">Runs on a linear-time engine: no look-around or back-references.</p>} |
| 525 | </div> |
| 526 | ); |
| 527 | } |
| 528 | case "file_path_restriction": |
| 529 | return ( |
| 530 | <PatternList |
| 531 | label="Restricted paths" |
| 532 | values={entry.parameters.restricted_file_paths} |
| 533 | placeholder=".g1t/workflows/**" |
| 534 | quick={[ |
| 535 | { value: ".g1t/workflows/**", label: "Workflows" }, |
| 536 | { value: "CODEOWNERS", label: "CODEOWNERS" }, |
| 537 | ]} |
| 538 | disabled={disabled} |
| 539 | onChange={(restricted_file_paths) => set({ restricted_file_paths })} |
| 540 | /> |
| 541 | ); |
| 542 | case "file_extension_restriction": |
| 543 | return ( |
| 544 | <PatternList |
| 545 | label="Restricted extensions" |
| 546 | values={entry.parameters.restricted_file_extensions} |
| 547 | placeholder=".exe" |
| 548 | quick={[ |
| 549 | { value: ".exe", label: ".exe" }, |
| 550 | { value: ".zip", label: ".zip" }, |
| 551 | ]} |
| 552 | disabled={disabled} |
| 553 | onChange={(restricted_file_extensions) => set({ restricted_file_extensions })} |
| 554 | /> |
| 555 | ); |
| 556 | case "max_file_size": |
| 557 | 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 })} />; |
| 558 | case "max_file_path_length": |
| 559 | 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 })} />; |
| 560 | case "max_files_changed": |
| 561 | 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 })} />; |
| 562 | case "confidence_threshold": { |
| 563 | const p = entry.parameters; |
| 564 | return ( |
| 565 | <div className="divide-y divide-line"> |
| 566 | <label className="flex items-center justify-between gap-4 py-1.5"> |
| 567 | <span className="text-sm text-fg">Changes rated below</span> |
| 568 | <Choice label="Lowest confidence that merges without people" value={p.minimum} options={LEVELS} disabled={disabled} onChange={(minimum) => set({ ...p, minimum })} /> |
| 569 | </label> |
| 570 | <NumberField label="need approvals from people" min={1} max={10} value={p.required_approvals} disabled={disabled} onChange={(required_approvals) => set({ ...p, required_approvals })} /> |
| 571 | </div> |
| 572 | ); |
| 573 | } |
| 574 | case "cost_cap": |
| 575 | 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 })} />; |
| 576 | case "path_review": { |
| 577 | const p = entry.parameters; |
| 578 | return ( |
| 579 | <div className="space-y-2"> |
| 580 | <PatternList label="Sensitive paths" values={p.paths} placeholder="infra/**" disabled={disabled} onChange={(paths) => set({ ...p, paths })} /> |
| 581 | <NumberField label="Approvals from people" min={1} max={10} value={p.required_approvals} disabled={disabled} onChange={(required_approvals) => set({ ...p, required_approvals })} /> |
| 582 | <label className="flex items-center justify-between gap-4 py-1.5"> |
| 583 | <span className="text-sm text-fg">From the team</span> |
| 584 | <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 })} /> |
| 585 | </label> |
| 586 | </div> |
| 587 | ); |
| 588 | } |
| 589 | case "merge_window": { |
| 590 | const p = entry.parameters; |
| 591 | return ( |
| 592 | <div className="space-y-4"> |
| 593 | <label className="flex items-center justify-between gap-4"> |
| 594 | <span className="text-sm text-fg">Time zone, as an offset from UTC</span> |
| 595 | <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 })} /> |
| 596 | </label> |
| 597 | <div> |
| 598 | <p className="text-sm text-fg">Weekly hours when merging is open</p> |
| 599 | <p className="mb-2 text-xs text-muted">None: open whenever no freeze covers the moment.</p> |
| 600 | <div className="space-y-2"> |
| 601 | {p.windows.map((window, index) => ( |
| 602 | <div key={index} className="flex flex-wrap items-center gap-2 rounded-lg border border-line p-2"> |
| 603 | <div className="flex flex-wrap gap-1"> |
| 604 | {DAYS.map((day) => { |
| 605 | const on = window.days.includes(day); |
| 606 | return ( |
| 607 | <button |
| 608 | key={day} |
| 609 | type="button" |
| 610 | aria-pressed={on} |
| 611 | disabled={disabled} |
| 612 | 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")} |
| 613 | onClick={() => |
| 614 | set({ |
| 615 | ...p, |
| 616 | windows: p.windows.map((one, at) => |
| 617 | at === index ? { ...one, days: on ? one.days.filter((other) => other !== day) : DAYS.filter((d) => d === day || one.days.includes(d)) } : one, |
| 618 | ), |
| 619 | }) |
| 620 | } |
| 621 | > |
| 622 | {DAY_LABEL[day]} |
| 623 | </button> |
| 624 | ); |
| 625 | })} |
| 626 | </div> |
| 627 | <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)) })} /> |
| 628 | <span className="text-sm text-muted">to</span> |
| 629 | <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)) })} /> |
| 630 | {!disabled && ( |
| 631 | <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) })}> |
| 632 | <X size={14} /> |
| 633 | </button> |
| 634 | )} |
| 635 | </div> |
| 636 | ))} |
| 637 | {!disabled && ( |
| 638 | <Button type="button" variant="quiet" onClick={() => set({ ...p, windows: [...p.windows, { days: ["mon", "tue", "wed", "thu", "fri"], start: "09:00", end: "17:00" }] })}> |
| 639 | <Plus size={14} /> Weekly hours |
| 640 | </Button> |
| 641 | )} |
| 642 | </div> |
| 643 | </div> |
| 644 | <div> |
| 645 | <p className="mb-2 text-sm text-fg">Freezes: merging waits</p> |
| 646 | <PeriodList label="Freeze" periods={p.freezes} allowOpen disabled={disabled} onChange={(freezes) => set({ ...p, freezes })} /> |
| 647 | </div> |
| 648 | <div> |
| 649 | <p className="mb-2 text-sm text-fg">Exceptions: merging is open whatever else says</p> |
| 650 | <PeriodList label="Exception" periods={p.exceptions} disabled={disabled} onChange={(exceptions) => set({ ...p, exceptions })} /> |
| 651 | </div> |
| 652 | </div> |
| 653 | ); |
| 654 | } |
| 655 | case "agent_auto_merge": { |
| 656 | const p = entry.parameters; |
| 657 | return ( |
| 658 | <div className="divide-y divide-line"> |
| 659 | <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 })} /> |
| 660 | <label className="flex items-center justify-between gap-4 py-1.5"> |
| 661 | <span className="text-sm text-fg">Only at this confidence or higher</span> |
| 662 | <Choice |
| 663 | label="Lowest confidence that merges by itself" |
| 664 | value={p.minimum_confidence ?? "any"} |
| 665 | options={[{ value: "any", label: "Any" }, ...LEVELS]} |
| 666 | disabled={disabled} |
| 667 | onChange={(value) => set({ ...p, minimum_confidence: value === "any" ? null : value })} |
| 668 | /> |
| 669 | </label> |
| 670 | </div> |
| 671 | ); |
| 672 | } |
| 673 | default: |
| 674 | return null; |
| 675 | } |
| 676 | } |
| 677 | |
| 678 | const APPLIES: { value: AppliesTo; label: string }[] = [ |
| 679 | { value: "everyone", label: "Everyone" }, |
| 680 | { value: "agents", label: "Agents' changes" }, |
| 681 | { value: "people", label: "People's changes" }, |
| 682 | ]; |
| 683 | |
| 684 | function RuleCard({ |
| 685 | entry, |
| 686 | onChange, |
| 687 | onRemove, |
| 688 | seen, |
| 689 | disabled, |
| 690 | }: { |
| 691 | entry: RuleEntry; |
| 692 | onChange: (entry: RuleEntry) => void; |
| 693 | onRemove: () => void; |
| 694 | seen: SeenCheck[]; |
| 695 | disabled?: boolean; |
| 696 | }) { |
| 697 | const info = ruleInfo(entry.type); |
| 698 | const agentsOnly = info?.group === "agents"; |
| 699 | return ( |
| 700 | <li className="rounded-xl border border-line bg-surface"> |
| 701 | <div className="flex flex-wrap items-start justify-between gap-3 p-4"> |
| 702 | <div className="min-w-0 grow basis-60"> |
| 703 | <p className="flex items-center gap-2 text-sm font-medium"> |
| 704 | {agentsOnly && <Bot size={14} className="text-accent" />} |
| 705 | {info?.label ?? entry.type} |
| 706 | </p> |
| 707 | <p className="mt-0.5 text-sm text-muted">{info?.about}</p> |
| 708 | </div> |
| 709 | <div className="flex items-center gap-2"> |
| 710 | <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 })} /> |
| 711 | {!disabled && ( |
| 712 | <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}> |
| 713 | <Trash2 size={15} /> |
| 714 | </button> |
| 715 | )} |
| 716 | </div> |
| 717 | </div> |
| 718 | {Object.keys(entry.parameters).length > 0 && ( |
| 719 | <div className="border-t border-line px-4 py-3"> |
| 720 | <Parameters entry={entry} onChange={onChange} seen={seen} disabled={disabled} /> |
| 721 | </div> |
| 722 | )} |
| 723 | </li> |
| 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) => ( |
| 745 | <li key={index} className="flex flex-wrap items-center gap-2 rounded-lg border border-line p-2"> |
| 746 | <Choice label="Who" value={actor.kind} options={KINDS} disabled={disabled} onChange={(kind) => update(index, { kind, value: kind === "role" ? "admin" : "" })} /> |
| 747 | {actor.kind === "role" ? ( |
| 748 | <Choice |
| 749 | label="Role" |
| 750 | value={actor.value || "admin"} |
| 751 | disabled={disabled} |
| 752 | options={[ |
| 753 | { value: "write", label: "Write and up" }, |
| 754 | { value: "maintain", label: "Maintain and up" }, |
| 755 | { value: "admin", label: "Admin" }, |
| 756 | { value: "owner", label: "Workspace owners" }, |
| 757 | ]} |
| 758 | onChange={(value) => update(index, { value })} |
| 759 | /> |
| 760 | ) : actor.kind === "g1t" ? ( |
| 761 | <span className="grow text-sm text-muted">g1t's agents and g1t itself (the merge queue, security updates)</span> |
| 762 | ) : ( |
| 763 | <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 })} /> |
| 764 | )} |
| 765 | <Choice |
| 766 | label="When" |
| 767 | value={actor.mode} |
| 768 | disabled={disabled} |
| 769 | options={[ |
| 770 | { value: "always", label: "Always" }, |
| 771 | { value: "pull_requests", label: "Pull requests only" }, |
| 772 | ]} |
| 773 | onChange={(mode) => update(index, { mode })} |
| 774 | /> |
| 775 | {!disabled && ( |
| 776 | <button type="button" aria-label={`Remove ${describeBypassActor(actor)}`} className="rounded p-1 text-faint hover:text-fg" onClick={() => onChange(actors.filter((_, at) => at !== index))}> |
| 777 | <X size={14} /> |
| 778 | </button> |
| 779 | )} |
| 780 | </li> |
| 781 | ))} |
| 782 | </ul> |
| 783 | {hasG1t && ( |
| 784 | <p className="flex items-start gap-2 text-xs text-warn"> |
| 785 | <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. |
| 786 | </p> |
| 787 | )} |
| 788 | {!disabled && ( |
| 789 | <Button type="button" variant="quiet" onClick={() => onChange([...actors, { kind: "role", value: "admin", mode: "pull_requests" }])}> |
| 790 | <Plus size={14} /> Add a bypass |
| 791 | </Button> |
| 792 | )} |
| 793 | </div> |
| 794 | ); |
| 795 | } |
| 796 | |
| 797 | // --- The form ----------------------------------------------------------------- |
| 798 | |
| 799 | function Block({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) { |
| 800 | return ( |
| 801 | <section className="grid gap-x-10 gap-y-3 border-t border-line pt-6 lg:grid-cols-[14rem_1fr]"> |
| 802 | <div> |
| 803 | <h2 className="font-medium">{title}</h2> |
| 804 | <p className="mt-1 text-sm text-muted">{about}</p> |
| 805 | </div> |
| 806 | <div className="min-w-0 space-y-3">{children}</div> |
| 807 | </section> |
| 808 | ); |
| 809 | } |
| 810 | |
| 811 | /** Downloads `ruleset` as a JSON file. */ |
| 812 | function download(ruleset: RulesetSpec) { |
| 813 | const blob = new Blob([JSON.stringify(exportRuleset(ruleset), null, 2) + "\n"], { type: "application/json" }); |
| 814 | const url = URL.createObjectURL(blob); |
| 815 | const link = document.createElement("a"); |
| 816 | link.href = url; |
| 817 | link.download = `${(ruleset.name || "ruleset").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "ruleset"}.json`; |
| 818 | link.click(); |
| 819 | URL.revokeObjectURL(url); |
| 820 | } |
| 821 | |
| 822 | /** |
| 823 | * Creating or changing a ruleset. The ruleset is posted whole, as JSON in |
| 824 | * `ruleset`, with `intent` `save`; deleting posts `intent` `delete`. |
| 825 | */ |
| 826 | export function RulesetForm({ |
| 827 | initial, |
| 828 | level, |
| 829 | existing, |
| 830 | seen = [], |
| 831 | editable, |
| 832 | error, |
| 833 | backHref, |
| 834 | repositoryLabel, |
| 835 | }: { |
| 836 | initial: RulesetSpec; |
| 837 | level: Level; |
| 838 | /** The ruleset being changed; absent for a new one. */ |
| 839 | existing?: Ruleset; |
| 840 | /** Check names reported lately, to require. */ |
| 841 | seen?: SeenCheck[]; |
| 842 | editable: boolean; |
| 843 | error?: string | null; |
| 844 | backHref: string; |
| 845 | /** For a repository's: its name, for the hint about the default branch. */ |
| 846 | repositoryLabel?: string; |
| 847 | }) { |
| 848 | const [spec, setSpec] = useState<RulesetSpec>(initial); |
| 849 | const [imported, setImported] = useState<string | null>(null); |
| 850 | const file = useRef<HTMLInputElement>(null); |
| 851 | const disabled = !editable; |
| 852 | const update = (change: Partial<RulesetSpec>) => setSpec((current) => ({ ...current, ...change })); |
| 853 | const addable = RULES.filter( |
| 854 | (info) => info.targets.includes(spec.target) && (info.repeatable || !spec.rules.some((rule) => rule.type === info.type && rule.applies_to === "everyone")), |
| 855 | ); |
| 856 | const fromBranchProtection = existing?.source === "branch_protection"; |
| 857 | return ( |
| 858 | <> |
| 859 | <Form method="post" className="space-y-6"> |
| 860 | <input type="hidden" name="intent" value="save" /> |
| 861 | <input type="hidden" name="ruleset" value={JSON.stringify(spec)} /> |
| 862 | {fromBranchProtection && ( |
| 863 | <p className="flex items-start gap-2 rounded-lg border border-info/30 bg-info/5 p-3 text-sm text-muted"> |
| 864 | <ShieldCheck size={15} className="mt-0.5 shrink-0 text-info" /> |
| 865 | Made from this repository's branch protection settings, holding exactly what they held. The pull request, status check and merge |
| 866 | queue rules here are also what Settings → Branches and the settings API read and write. |
| 867 | </p> |
| 868 | )} |
| 869 | <section className="grid gap-4 md:grid-cols-[1fr_auto]"> |
| 870 | <label className="block"> |
| 871 | <span className="mb-1.5 block text-sm font-medium text-muted">Name</span> |
| 872 | <Input name="name" value={spec.name} disabled={disabled} maxLength={100} placeholder="Protect main" required onChange={(event) => update({ name: event.target.value })} /> |
| 873 | </label> |
| 874 | <div> |
| 875 | <span className="mb-1.5 block text-sm font-medium text-muted">Targets</span> |
| 876 | <div className="flex rounded-md border border-line p-0.5" role="radiogroup" aria-label="Targets"> |
| 877 | {(["branch", "tag"] as const).map((target) => ( |
| 878 | <button |
| 879 | key={target} |
| 880 | type="button" |
| 881 | role="radio" |
| 882 | aria-checked={spec.target === target} |
| 883 | disabled={disabled} |
| 884 | 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")} |
| 885 | onClick={() => |
| 886 | update({ |
| 887 | target, |
| 888 | conditions: { ...spec.conditions, ref_name: { include: target === "tag" ? [] : [DEFAULT_BRANCH], exclude: [] } }, |
| 889 | rules: spec.rules.filter((rule) => ruleInfo(rule.type)?.targets.includes(target)), |
| 890 | }) |
| 891 | } |
| 892 | > |
| 893 | {target === "branch" ? <GitBranch size={14} /> : <Tag size={14} />} |
| 894 | {target === "branch" ? "Branches" : "Tags"} |
| 895 | </button> |
| 896 | ))} |
| 897 | </div> |
| 898 | </div> |
| 899 | </section> |
| 900 | |
| 901 | <Block title="Enforcement" about="Try a ruleset in evaluate first: Insights shows what it would have refused."> |
| 902 | <RadioGroup value={spec.enforcement} onValueChange={(value) => update({ enforcement: value as Enforcement })} disabled={disabled} className="grid gap-3 sm:grid-cols-3"> |
| 903 | {(Object.keys(ENFORCEMENT) as Enforcement[]).map((enforcement) => ( |
| 904 | <RadioOption key={enforcement} value={enforcement} label={ENFORCEMENT[enforcement].label} description={ENFORCEMENT[enforcement].about} /> |
| 905 | ))} |
| 906 | </RadioGroup> |
| 907 | </Block> |
| 908 | |
| 909 | <Block |
| 910 | title={spec.target === "branch" ? "Branches" : "Tags"} |
| 911 | about={ |
| 912 | <> |
| 913 | Names it holds for: fnmatch patterns, <code>*</code> within a segment and <code>**</code> across them. |
| 914 | {repositoryLabel ? ` Default branch follows ${repositoryLabel}'s default branch if it is renamed.` : ""} |
| 915 | </> |
| 916 | } |
| 917 | > |
| 918 | <div> |
| 919 | <p className="mb-2 text-sm text-fg">Include</p> |
| 920 | <PatternList |
| 921 | label="Include" |
| 922 | values={spec.conditions.ref_name.include} |
| 923 | placeholder={spec.target === "branch" ? "release/*" : "v*"} |
| 924 | quick={ |
| 925 | spec.target === "branch" |
| 926 | ? [ |
| 927 | { value: DEFAULT_BRANCH, label: "Default branch" }, |
| 928 | { value: ALL, label: "All branches" }, |
| 929 | ] |
| 930 | : [{ value: ALL, label: "All tags" }] |
| 931 | } |
| 932 | disabled={disabled} |
| 933 | onChange={(include) => update({ conditions: { ...spec.conditions, ref_name: { ...spec.conditions.ref_name, include } } })} |
| 934 | /> |
| 935 | </div> |
| 936 | <div> |
| 937 | <p className="mb-2 text-sm text-fg">Exclude</p> |
| 938 | <PatternList |
| 939 | label="Exclude" |
| 940 | values={spec.conditions.ref_name.exclude} |
| 941 | placeholder={spec.target === "branch" ? "dependabot/**" : "v*-rc*"} |
| 942 | disabled={disabled} |
| 943 | onChange={(exclude) => update({ conditions: { ...spec.conditions, ref_name: { ...spec.conditions.ref_name, exclude } } })} |
| 944 | /> |
| 945 | </div> |
| 946 | </Block> |
| 947 | |
| 948 | {level === "workspace" && spec.conditions.repository && ( |
| 949 | <Block title="Repositories" about="Which of the workspace's repositories it holds in."> |
| 950 | <div> |
| 951 | <p className="mb-2 text-sm text-fg">Names to include</p> |
| 952 | <PatternList |
| 953 | label="Repositories to include" |
| 954 | values={spec.conditions.repository.include} |
| 955 | placeholder="api-*" |
| 956 | quick={[{ value: ALL, label: "All repositories" }]} |
| 957 | disabled={disabled} |
| 958 | onChange={(include) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, include } } })} |
| 959 | /> |
| 960 | </div> |
| 961 | <div> |
| 962 | <p className="mb-2 text-sm text-fg">Names to exclude</p> |
| 963 | <PatternList |
| 964 | label="Repositories to exclude" |
| 965 | values={spec.conditions.repository.exclude} |
| 966 | placeholder="sandbox-*" |
| 967 | disabled={disabled} |
| 968 | onChange={(exclude) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, exclude } } })} |
| 969 | /> |
| 970 | </div> |
| 971 | <label className="flex items-center justify-between gap-4"> |
| 972 | <span className="text-sm text-fg">Visibility</span> |
| 973 | <Choice |
| 974 | label="Visibility" |
| 975 | value={spec.conditions.repository.visibility} |
| 976 | disabled={disabled} |
| 977 | options={[ |
| 978 | { value: "any", label: "Public and private" }, |
| 979 | { value: "public", label: "Public only" }, |
| 980 | { value: "private", label: "Private only" }, |
| 981 | ]} |
| 982 | onChange={(visibility) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, visibility } } })} |
| 983 | /> |
| 984 | </label> |
| 985 | <div> |
| 986 | <p className="text-sm text-fg">Topics</p> |
| 987 | <p className="mb-2 text-xs text-muted">Only repositories carrying one of these. Empty: any.</p> |
| 988 | <PatternList |
| 989 | label="Topics" |
| 990 | values={spec.conditions.repository.topics} |
| 991 | placeholder="payments" |
| 992 | mono={false} |
| 993 | disabled={disabled} |
| 994 | onChange={(topics) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, topics } } })} |
| 995 | /> |
| 996 | </div> |
| 997 | </Block> |
| 998 | )} |
| 999 | |
| 1000 | <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."> |
| 1001 | <BypassList actors={spec.bypass_actors} disabled={disabled} onChange={(bypass_actors) => update({ bypass_actors })} /> |
| 1002 | </Block> |
| 1003 | |
| 1004 | <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."> |
| 1005 | {spec.rules.length === 0 && <p className="text-sm text-muted">No rules yet.</p>} |
| 1006 | <ul className="space-y-3"> |
| 1007 | {spec.rules.map((entry, index) => ( |
| 1008 | <RuleCard |
| 1009 | key={`${entry.type}-${index}`} |
| 1010 | entry={entry} |
| 1011 | seen={seen} |
| 1012 | disabled={disabled} |
| 1013 | onChange={(changed) => update({ rules: spec.rules.map((one, at) => (at === index ? changed : one)) })} |
| 1014 | onRemove={() => update({ rules: spec.rules.filter((_, at) => at !== index) })} |
| 1015 | /> |
| 1016 | ))} |
| 1017 | </ul> |
| 1018 | {editable && ( |
| 1019 | <DropdownMenu> |
| 1020 | <DropdownMenuTrigger asChild> |
| 1021 | <Button type="button" variant="quiet"> |
| 1022 | <Plus size={14} /> Add a rule |
| 1023 | </Button> |
| 1024 | </DropdownMenuTrigger> |
| 1025 | <DropdownMenuContent align="start" className="max-h-[60vh] w-80 overflow-y-auto"> |
| 1026 | {RULE_GROUPS.map((group, groupIndex) => { |
| 1027 | const rules = addable.filter((info) => info.group === group.id); |
| 1028 | if (rules.length === 0) return null; |
| 1029 | return ( |
| 1030 | <div key={group.id}> |
| 1031 | {groupIndex > 0 && <DropdownMenuSeparator />} |
| 1032 | <DropdownMenuLabel>{group.label}</DropdownMenuLabel> |
| 1033 | {rules.map((info) => ( |
| 1034 | <DropdownMenuItem key={info.type} onSelect={() => update({ rules: [...spec.rules, newRule(info.type as RuleType)] })}> |
| 1035 | <span className="min-w-0"> |
| 1036 | <span className="block text-sm">{info.label}</span> |
| 1037 | <span className="block text-xs text-muted">{info.about}</span> |
| 1038 | </span> |
| 1039 | </DropdownMenuItem> |
| 1040 | ))} |
| 1041 | </div> |
| 1042 | ); |
| 1043 | })} |
| 1044 | </DropdownMenuContent> |
| 1045 | </DropdownMenu> |
| 1046 | )} |
| 1047 | </Block> |
| 1048 | |
| 1049 | <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"> |
| 1050 | {editable && ( |
| 1051 | <SubmitButton pending="Saving…" match={{ intent: "save" }}> |
| 1052 | {existing ? "Save changes" : "Create ruleset"} |
| 1053 | </SubmitButton> |
| 1054 | )} |
| 1055 | <Link to={backHref} className="text-sm text-muted hover:text-fg"> |
| 1056 | {editable ? "Cancel" : "Back to rules"} |
| 1057 | </Link> |
| 1058 | <span className="grow" /> |
| 1059 | <Button type="button" variant="quiet" onClick={() => download(spec)}> |
| 1060 | <Download size={14} /> Export JSON |
| 1061 | </Button> |
| 1062 | {editable && ( |
| 1063 | <> |
| 1064 | <input |
| 1065 | ref={file} |
| 1066 | type="file" |
| 1067 | accept="application/json,.json" |
| 1068 | className="hidden" |
| 1069 | onChange={async (event) => { |
| 1070 | const chosen = event.target.files?.[0]; |
| 1071 | event.target.value = ""; |
| 1072 | if (!chosen) return; |
| 1073 | try { |
| 1074 | setSpec(importRuleset(await chosen.text(), level)); |
| 1075 | setImported(`Imported ${chosen.name}. Check it, then save.`); |
| 1076 | } catch (problem) { |
| 1077 | setImported(problem instanceof Error ? problem.message : "That file could not be read."); |
| 1078 | } |
| 1079 | }} |
| 1080 | /> |
| 1081 | <Button type="button" variant="quiet" onClick={() => file.current?.click()}> |
| 1082 | <FileUp size={14} /> Import JSON |
| 1083 | </Button> |
| 1084 | </> |
| 1085 | )} |
| 1086 | {imported && <p className="w-full text-sm text-muted">{imported}</p>} |
| 1087 | <div className="w-full"> |
| 1088 | <ErrorText>{error}</ErrorText> |
| 1089 | </div> |
| 1090 | </div> |
| 1091 | </Form> |
| 1092 | {editable && existing && ( |
| 1093 | <Form method="post" className="mt-8 rounded-xl border border-danger/30 p-4"> |
| 1094 | <input type="hidden" name="intent" value="delete" /> |
| 1095 | <h2 className="font-medium">Delete this ruleset</h2> |
| 1096 | <p className="mt-1 text-sm text-muted">Its rules stop holding at once. Its evaluations stay in Insights.</p> |
| 1097 | <div className="mt-3"> |
| 1098 | <SubmitButton variant="danger" pending="Deleting…" match={{ intent: "delete" }}> |
| 1099 | <Trash2 size={14} /> Delete ruleset |
| 1100 | </SubmitButton> |
| 1101 | </div> |
| 1102 | </Form> |
| 1103 | )} |
| 1104 | </> |
| 1105 | ); |
| 1106 | } |
| 1107 | |
| 1108 | // --- The list ----------------------------------------------------------------- |
| 1109 | |
| 1110 | function RulesetRow({ ruleset, href, inherited }: { ruleset: Ruleset; href: string; inherited?: boolean }) { |
| 1111 | const agents = ruleset.rules.some((rule) => rule.applies_to === "agents" || ruleInfo(rule.type)?.group === "agents"); |
| 1112 | return ( |
| 1113 | <li> |
| 1114 | <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"> |
| 1115 | <span className="mt-0.5 shrink-0 text-muted"> |
| 1116 | {ruleset.enforcement === "disabled" ? <ShieldOff size={16} /> : <ShieldCheck size={16} className={ruleset.enforcement === "active" ? "text-accent" : "text-info"} />} |
| 1117 | </span> |
| 1118 | <span className="min-w-0 grow basis-48"> |
| 1119 | <span className="flex flex-wrap items-center gap-2"> |
| 1120 | <span className="font-medium group-hover:underline">{ruleset.name}</span> |
| 1121 | <EnforcementBadge enforcement={ruleset.enforcement} /> |
| 1122 | {inherited && <Badge>From the workspace</Badge>} |
| 1123 | {ruleset.source === "branch_protection" && <Badge>Branch protection</Badge>} |
| 1124 | {agents && ( |
| 1125 | <Badge tone="merged"> |
| 1126 | <Bot size={11} /> Agent rules |
| 1127 | </Badge> |
| 1128 | )} |
| 1129 | </span> |
| 1130 | <span className="mt-1 block text-sm text-muted"> |
| 1131 | {ruleset.target === "tag" ? "Tags" : "Branches"}: {targetSummary(ruleset)} · {ruleset.rules.length} {ruleset.rules.length === 1 ? "rule" : "rules"} |
| 1132 | {ruleset.bypass_actors.length > 0 && ` · ${ruleset.bypass_actors.length} may bypass`} |
| 1133 | </span> |
| 1134 | </span> |
| 1135 | <span className="shrink-0 text-xs text-faint"> |
| 1136 | Changed <TimeAgo at={ruleset.updated_at} /> by <span className="font-mono">{ruleset.updated_by}</span> |
| 1137 | </span> |
| 1138 | </Link> |
| 1139 | </li> |
| 1140 | ); |
| 1141 | } |
| 1142 | |
| 1143 | export function RulesetList({ |
| 1144 | rulesets, |
| 1145 | hrefFor, |
| 1146 | level, |
| 1147 | empty, |
| 1148 | }: { |
| 1149 | rulesets: Ruleset[]; |
| 1150 | hrefFor: (ruleset: Ruleset) => string; |
| 1151 | level: Level; |
| 1152 | empty: ReactNode; |
| 1153 | }) { |
| 1154 | if (rulesets.length === 0) { |
| 1155 | return <div className="rounded-xl border border-dashed border-line p-8 text-center text-sm text-muted">{empty}</div>; |
| 1156 | } |
| 1157 | return ( |
| 1158 | <ul className="space-y-2"> |
| 1159 | {rulesets.map((ruleset) => ( |
| 1160 | <RulesetRow key={ruleset.id} ruleset={ruleset} href={hrefFor(ruleset)} inherited={level === "repository" && ruleset.level === "workspace"} /> |
| 1161 | ))} |
| 1162 | </ul> |
| 1163 | ); |
| 1164 | } |
| 1165 | |
| 1166 | // --- Effective rules ---------------------------------------------------------- |
| 1167 | |
| 1168 | /** Every rule that holds for one branch, grouped by where it comes from. */ |
| 1169 | export function EffectiveRulesView({ effective, hrefFor }: { effective: EffectiveRules; hrefFor: (id: string, level: Level) => string }) { |
| 1170 | if (effective.rules.length === 0) { |
| 1171 | return ( |
| 1172 | <p className="rounded-xl border border-dashed border-line p-6 text-sm text-muted"> |
| 1173 | No ruleset targets <span className="font-mono text-fg">{effective.name}</span>: anyone who may push can change it however they like. |
| 1174 | </p> |
| 1175 | ); |
| 1176 | } |
| 1177 | return ( |
| 1178 | <div className="space-y-3"> |
| 1179 | {effective.rulesets.map((ruleset) => { |
| 1180 | const rules = effective.rules.filter((rule) => rule.ruleset_id === ruleset.id); |
| 1181 | return ( |
| 1182 | <div key={ruleset.id} className="rounded-xl border border-line"> |
| 1183 | <div className="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2.5"> |
| 1184 | <Link to={hrefFor(ruleset.id, ruleset.level)} className="font-medium hover:underline"> |
| 1185 | {ruleset.name} |
| 1186 | </Link> |
| 1187 | <EnforcementBadge enforcement={ruleset.enforcement} /> |
| 1188 | {ruleset.level === "workspace" && <Badge>Workspace</Badge>} |
| 1189 | <span className="grow" /> |
| 1190 | <span className="flex items-center gap-1 text-xs text-muted"> |
| 1191 | <Users size={12} /> |
| 1192 | {ruleset.bypass_actors.length === 0 ? "Nobody bypasses" : ruleset.bypass_actors.map(describeBypassActor).join(", ")} |
| 1193 | </span> |
| 1194 | </div> |
| 1195 | <ul className="divide-y divide-line"> |
| 1196 | {rules.map((rule, index) => ( |
| 1197 | <li key={index} className="flex flex-wrap items-center gap-2 px-4 py-2 text-sm"> |
| 1198 | <span className="grow">{ruleInfo(rule.type)?.label ?? rule.type}</span> |
| 1199 | {rule.applies_to !== "everyone" && <Badge tone="merged">{describeAppliesTo(rule.applies_to)}</Badge>} |
| 1200 | </li> |
| 1201 | ))} |
| 1202 | </ul> |
| 1203 | </div> |
| 1204 | ); |
| 1205 | })} |
| 1206 | </div> |
| 1207 | ); |
| 1208 | } |
| 1209 | |
| 1210 | // --- Insights ----------------------------------------------------------------- |
| 1211 | |
| 1212 | function Stat({ label, value, tone }: { label: string; value: number; tone?: "danger" | "info" | "warn" }) { |
| 1213 | return ( |
| 1214 | <div className="rounded-xl border border-line p-3"> |
| 1215 | <p className="text-xs text-muted">{label}</p> |
| 1216 | <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> |
| 1217 | </div> |
| 1218 | ); |
| 1219 | } |
| 1220 | |
| 1221 | const ACTION_LABEL: Record<string, string> = { |
| 1222 | push: "Push", |
| 1223 | merge: "Merge", |
| 1224 | create_ref: "Create", |
| 1225 | delete_ref: "Delete", |
| 1226 | rename_ref: "Rename", |
| 1227 | commit: "Commit", |
| 1228 | }; |
| 1229 | |
| 1230 | function verdictBadge(evaluation: Evaluation) { |
| 1231 | if (evaluation.verdict === "pass") return <Badge tone="accent">Passed</Badge>; |
| 1232 | if (evaluation.verdict === "bypass") return <Badge tone="warn">Bypassed</Badge>; |
| 1233 | if (evaluation.enforcement === "evaluate") return <Badge tone="info">Would block</Badge>; |
| 1234 | return <Badge tone="danger">Blocked</Badge>; |
| 1235 | } |
| 1236 | |
| 1237 | /** How the rules judged pushes and merges: the last 30 days, and each evaluation. */ |
| 1238 | export function InsightsView({ page, showRepository, olderHref }: { page: EvaluationPage; showRepository?: boolean; olderHref?: string | null }) { |
| 1239 | const insights = page.insights; |
| 1240 | return ( |
| 1241 | <div className="space-y-6"> |
| 1242 | <div className="grid grid-cols-2 gap-2 sm:grid-cols-5"> |
| 1243 | <Stat label={`Evaluations, ${insights.days} days`} value={insights.total} /> |
| 1244 | <Stat label="Passed" value={insights.passed} /> |
| 1245 | <Stat label="Blocked" value={insights.blocked} tone="danger" /> |
| 1246 | <Stat label="Would block" value={insights.would_block} tone="info" /> |
| 1247 | <Stat label="Bypassed" value={insights.bypassed} tone="warn" /> |
| 1248 | </div> |
| 1249 | {insights.by_rule.length > 0 && ( |
| 1250 | <div> |
| 1251 | <h3 className="mb-2 text-sm font-medium">Rules broken most</h3> |
| 1252 | <ul className="space-y-1.5"> |
| 1253 | {insights.by_rule.slice(0, 6).map((row) => { |
| 1254 | const top = insights.by_rule[0]!.count || 1; |
| 1255 | return ( |
| 1256 | <li key={row.rule} className="flex items-center gap-3 text-sm"> |
| 1257 | <span className="w-56 shrink-0 truncate">{ruleInfo(row.rule)?.label ?? row.rule}</span> |
| 1258 | <span className="h-2 grow overflow-hidden rounded-full bg-surface"> |
| 1259 | <span className="block h-full rounded-full bg-danger/70" style={{ width: `${Math.max(4, (row.count / top) * 100)}%` }} /> |
| 1260 | </span> |
| 1261 | <span className="w-10 shrink-0 text-right tabular-nums text-muted">{row.count}</span> |
| 1262 | </li> |
| 1263 | ); |
| 1264 | })} |
| 1265 | </ul> |
| 1266 | </div> |
| 1267 | )} |
| 1268 | <div> |
| 1269 | <h3 className="mb-2 text-sm font-medium">Recent evaluations</h3> |
| 1270 | {page.evaluations.length === 0 ? ( |
| 1271 | <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> |
| 1272 | ) : ( |
| 1273 | <ul className="divide-y divide-line rounded-xl border border-line"> |
| 1274 | {page.evaluations.map((evaluation) => ( |
| 1275 | <li key={evaluation.id} className="px-4 py-3"> |
| 1276 | <div className="flex flex-wrap items-center gap-2 text-sm"> |
| 1277 | {verdictBadge(evaluation)} |
| 1278 | <span className="font-medium">{ACTION_LABEL[evaluation.action] ?? evaluation.action}</span> |
| 1279 | <span className="font-mono text-[0.8125rem] text-muted">{evaluation.git_ref.replace(/^refs\/(heads|tags)\//, "")}</span> |
| 1280 | {evaluation.number != null && <span className="text-muted">#{evaluation.number}</span>} |
| 1281 | <span className="text-muted"> |
| 1282 | by <span className="font-mono">{evaluation.actor}</span> |
| 1283 | {evaluation.actor_kind !== "person" && <Bot size={12} className="ml-1 inline text-accent" />} |
| 1284 | </span> |
| 1285 | {showRepository && evaluation.repository && <span className="text-faint">{evaluation.repository}</span>} |
| 1286 | <span className="grow" /> |
| 1287 | <span className="text-xs text-faint"> |
| 1288 | {evaluation.ruleset_name} · <TimeAgo at={evaluation.created_at} /> |
| 1289 | </span> |
| 1290 | </div> |
| 1291 | {evaluation.violations.length > 0 && ( |
| 1292 | <ul className="mt-1.5 space-y-0.5"> |
| 1293 | {evaluation.violations.map((violation, index) => ( |
| 1294 | <li key={index} className="text-sm text-muted"> |
| 1295 | {violation.message} |
| 1296 | </li> |
| 1297 | ))} |
| 1298 | </ul> |
| 1299 | )} |
| 1300 | </li> |
| 1301 | ))} |
| 1302 | </ul> |
| 1303 | )} |
| 1304 | {olderHref && ( |
| 1305 | <Link to={olderHref} className="mt-3 inline-block text-sm text-muted hover:text-fg"> |
| 1306 | Older evaluations → |
| 1307 | </Link> |
| 1308 | )} |
| 1309 | </div> |
| 1310 | </div> |
| 1311 | ); |
| 1312 | } |
| 1313 | |
| 1314 | // --- The merge box ------------------------------------------------------------ |
| 1315 | |
| 1316 | /** Rules a pull request does not meet, each with its ruleset and how to meet it. */ |
| 1317 | export function ViolationList({ violations, tone = "danger" }: { violations: Violation[]; tone?: "danger" | "info" | "warn" }) { |
| 1318 | const grouped = useMemo(() => { |
| 1319 | const seen = new Set<string>(); |
| 1320 | return violations.filter((violation) => { |
| 1321 | const key = `${violation.rule}:${violation.message}`; |
| 1322 | if (seen.has(key)) return false; |
| 1323 | seen.add(key); |
| 1324 | return true; |
| 1325 | }); |
| 1326 | }, [violations]); |
| 1327 | return ( |
| 1328 | <ul className="space-y-2"> |
| 1329 | {grouped.map((violation, index) => ( |
| 1330 | <li key={index} className="flex items-start gap-2 text-sm"> |
| 1331 | <ShieldAlert size={14} className={cn("mt-0.5 shrink-0", tone === "danger" ? "text-danger" : tone === "info" ? "text-info" : "text-warn")} /> |
| 1332 | <span className="min-w-0"> |
| 1333 | <span className="text-fg">{violation.message}</span>{" "} |
| 1334 | {violation.remedy && <span className="text-muted">{violation.remedy}</span>} |
| 1335 | <span className="mt-0.5 block text-xs text-faint"> |
| 1336 | {ruleInfo(violation.rule)?.label ?? violation.rule} · {violation.ruleset_name} |
| 1337 | </span> |
| 1338 | </span> |
| 1339 | </li> |
| 1340 | ))} |
| 1341 | </ul> |
| 1342 | ); |
| 1343 | } |