Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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