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