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