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