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,321 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1/**
2 * Rulesets on the site: the list, the form that creates and changes one
3 * (every rule with its parameters), the rules that hold for one branch,
4 * how the rules judged pushes and merges, and the violations a pull
5 * request's merge box lists. Used by a repository's Settings → Rules and a
6 * workspace's.
7 */
8import {
9 Bot,
10 Download,
11 FileUp,
12 GitBranch,
13 Plus,
14 ShieldAlert,
15 ShieldCheck,
16 ShieldOff,
17 Tag,
18 Trash2,
19 TriangleAlert,
20 Users,
21 X,
22} from "lucide-react";
23import { type ReactNode, useMemo, useRef, useState } from "react";
24import { Form, Link } from "react-router";
25
26import type {
27 AppliesTo,
28 BypassActor,
29 BypassActorKind,
30 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";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.61import { ErrorText, SubmitButton, TimeAgo } from "./ui";
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge62import { Badge } from "./ui/badge";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.63import { Button } from "./ui/button";
64import { Card } from "./ui/card";
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge65import { 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 && (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.118 <Button
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge119 type="button"
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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))}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge121 <X size={13} />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.122 </Button>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge123 )}
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 />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.143 <Button type="button" variant="outline" onClick={() => add(typed)} disabled={!typed.trim()}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge144 <Plus size={14} /> Add
145 </Button>
146 {quick
147 .filter((option) => !values.includes(option.value))
148 .map((option) => (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.149 <Button
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge150 key={option.value}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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)}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge152 + {option.label}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.153 </Button>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge154 ))}
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) => (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.299 <Card key={index} tone="plain" radius="lg" className="flex flex-wrap items-center gap-2 p-2">
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge300 <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 && (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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))}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge327 <X size={14} />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.328 </Button>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge329 )}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.330 </Card>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge331 ))}
332 {!disabled && (
333 <div className="flex flex-wrap gap-2">
334 <Button
335 type="button"
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.336 variant="outline"
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge337 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 && (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.342 <Button type="button" variant="outline" onClick={() => onChange([...periods, { start: new Date().toISOString(), end: null, reason: "Incident" }])}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge343 <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) => (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge452 ))}
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) => (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.580 <Card key={index} tone="plain" radius="lg" className="flex flex-wrap items-center gap-2 p-2">
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge581 <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 && (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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) })}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge610 <X size={14} />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.611 </Button>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge612 )}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.613 </Card>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge614 ))}
615 {!disabled && (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.616 <Button type="button" variant="outline" onClick={() => set({ ...p, windows: [...p.windows, { days: ["mon", "tue", "wed", "thu", "fri"], start: "09:00", end: "17:00" }] })}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge617 <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 (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge686 </div>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge694 );
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) => (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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 )}
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge736 <Choice
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.737 label="When"
738 value={actor.mode}
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge739 disabled={disabled}
740 options={[
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.741 { value: "always", label: "Always" },
742 { value: "pull_requests", label: "Pull requests only" },
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge743 ]}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.744 onChange={(mode) => update(index, { mode })}
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge745 />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge753 ))}
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 && (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.761 <Button type="button" variant="outline" onClick={() => onChange([...actors, { kind: "role", value: "admin", mode: "pull_requests" }])}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge762 <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>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.993 <Button type="button" variant="outline">
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge994 <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
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar1021 <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">
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1022 {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" />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.1031 <Button type="button" variant="outline" onClick={() => download(spec)}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1032 <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 />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.1053 <Button type="button" variant="outline" onClick={() => file.current?.click()}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1054 <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">
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.1070 <SubmitButton variant="destructive" pending="Deleting…" match={{ intent: "delete" }}>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1071 <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) {
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.1127 return <Card tone="plain" className="border-dashed p-8 text-center text-sm text-muted">{empty}</Card>;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1128 }
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 (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1149 );
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 (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.1156 <Card key={ruleset.id} tone="plain">
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1157 <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>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.1177 </Card>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1178 );
1179 })}
1180 </div>
1181 );
1182}
1183
1184// --- Insights -----------------------------------------------------------------
1185
1186function Stat({ label, value, tone }: { label: string; value: number; tone?: "danger" | "info" | "warn" }) {
1187 return (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.1188 <Card tone="plain" className="p-3">
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1189 <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>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.1191 </Card>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1192 );
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">
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar1231 <span className="w-32 shrink-0 truncate sm:w-56">{ruleInfo(row.rule)?.label ?? row.rule}</span>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1232 <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 ? (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1248 ) : (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.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>
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1281 )}
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}

This file's history is long; its oldest lines are credited to the oldest commit read.