Skip to content

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

1,342 lines59,255 bytesCodeBlame

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 merge1/**
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 ]}
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
677const 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
683function 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
728const 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
736function 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
798function 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. */
811function 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 */
825export 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
1109function 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
1142export 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. */
1168export 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
1211function 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
1220const 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
1229function 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. */
1237export 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. */
1316export 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.