Skip to content

g1t/apps/web/app/components/rules.tsx

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