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