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.

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