Skip to content

Commit

Rules on the site: rulesets for a repository and a workspace, and the merge box

- Settings → Rules for a repository: its rulesets and the workspace's that hold in it, what holds for any branch or tag (every rule, where it comes from, who may bypass it), and Insights: 30 days of evaluations, blocked, would block and bypassed, the rules broken most, and each push and merge as the rules judged it. - A ruleset's page: name, targets (branches or tags, patterns with the default branch and all), enforcement with evaluate as a dry run, the repositories a workspace's selects, the bypass list (g1t only when listed, with a warning), and every rule with its parameters, each for everyone, agents' changes or people's: required checks pinned to an integration or held to some paths, merge windows with weekly hours, freezes (one click freezes until lifted) and exceptions, confidence thresholds, cost caps, sensitive paths by team, and the rest. Export and import as JSON; delete. - The same for a workspace under its Settings → Rules, owners editing. - Branches and merging keeps how g1t's agents work and CODEOWNERS, and shows what holds for the default branch with a link to Rules; the protection toggles moved there. - The merge box lists each rule not met with its ruleset and how to meet it, what the viewer may bypass (a box to bypass, recorded), and what rulesets in evaluate would refuse; the button says why it cannot merge. - @g1t/contracts rules.ts mirrors the Rust types; the site's lib/rules.ts holds how each rule is shown and set up, with tests.

syntaqxcommitted Parent72405d9Browse files
21 files+2580−3140/21 viewed
+2−1
1010 guardrails: { title: "Guardrails", about: "What agents may reach, run and spend while they work here." },
1111 repository: { title: "Repository", about: "Its name, details and default branch, who can see it, and archiving, moving or deleting it." },
1212 access: { title: "Access", about: "Who can see and change the repository, with which role, and invitations to it." },
13− branches: { title: "Branches and merging", about: "Protection for the default branch, what a pull request needs before it merges, and what agents do." },
13+ branches: { title: "Branches and merging", about: "How pull requests merge, what g1t's agents do with theirs, and who owns which files." },
14+ rules: { title: "Rules", about: "Rulesets: what may happen to branches and tags, what a pull request needs before it merges, and how the rules judged each push and merge." },
1415 secrets: { title: "Secrets and variables", about: "Values workflows, builds and deployments read at run time." },
1516 runners: { title: "Runners", about: "Machines of your own for this project's workflow jobs, and where its agents' work runs." },
1617 webhooks: { title: "Webhooks", about: "Addresses g1t calls when something happens in the project." },
+0−137
1−/**
2− * Choosing the checks a branch requires: every name reported on the
3− * repository's commits lately, with the events it was reported for, ticked
4− * when it is required, and a field for a name nothing has reported yet.
5− * Posts each ticked name as `requiredChecks`.
6− */
7−import { Plus, X } from "lucide-react";
8−import { useState } from "react";
9−
10−import type { SeenCheck } from "@g1t/contracts";
11−
12−import { CheckboxOption } from "./ui/checkbox";
13−import { TimeAgo } from "./ui";
14−
15−/** The events a workflow reports for that matter to a merge, first. */
16−const EVENT_ORDER = ["pull_request", "merge_group", "pull_request_target", "push"];
17−
18−function events(list: string[]): string {
19− const sorted = [...list].sort((a, b) => {
20− const ia = EVENT_ORDER.indexOf(a);
21− const ib = EVENT_ORDER.indexOf(b);
22− return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.localeCompare(b);
23− });
24− return sorted.join(", ");
25−}
26−
27−/** What to say under a name: where it was seen, and whether that is enough to require it. */
28−function about(seen: SeenCheck | undefined, mergeQueue: boolean) {
29− if (!seen) return <>Not reported in the last 30 days. Until something reports it, nothing can merge.</>;
30− const onPulls = seen.events.length === 0 || seen.events.some((event) => event.startsWith("pull_request"));
31− const onQueue = seen.events.length === 0 || seen.events.includes("merge_group");
32− return (
33− <>
34− {seen.events.length > 0 ? `Reported for ${events(seen.events)}` : "Reported by a tool other than a workflow"} ·{" "}
35− <TimeAgo at={seen.lastSeen} />
36− {!onPulls && <span className="text-warn"> · not on pull requests, so it would hold every merge</span>}
37− {onPulls && mergeQueue && !onQueue && <span className="text-warn"> · not on merge_group, so the queue cannot pass</span>}
38− </>
39− );
40−}
41−
42−export function RequiredChecksPicker({
43− required,
44− seen,
45− mergeQueue,
46− disabled,
47−}: {
48− required: string[];
49− seen: SeenCheck[];
50− mergeQueue: boolean;
51− disabled?: boolean;
52−}) {
53− const [added, setAdded] = useState<string[]>([]);
54− const [typed, setTyped] = useState("");
55− const known = (name: string, list: string[]) => list.some((other) => other.toLowerCase() === name.toLowerCase());
56− // Required ones first, as they are; then the rest seen lately; then what was typed.
57− const names = [
58− ...required,
59− ...seen.map((check) => check.name).filter((name) => !known(name, required)),
60− ...added.filter((name) => !known(name, required) && !seen.some((check) => check.name.toLowerCase() === name.toLowerCase())),
61− ];
62− const add = () => {
63− const name = typed.trim();
64− if (name && !known(name, names)) setAdded([...added, name]);
65− setTyped("");
66− };
67− return (
68− <div className="rounded-xl border border-line bg-surface p-4">
69− <p className="text-sm font-medium">Require status checks to pass before merging</p>
70− <p className="mt-1 text-sm text-muted">
71− A pull request merges only once each check ticked here has passed on its latest commit: for people and agents
72− alike, by hand, by auto-merge and through the queue. A workflow reports a check named after it.
73− </p>
74− {names.length === 0 ? (
75− <p className="mt-3 rounded-lg border border-dashed border-line px-3 py-3 text-sm text-faint">
76− No checks have been reported on this repository in the last 30 days.
77− </p>
78− ) : (
79− <ul className="mt-3 divide-y divide-line rounded-lg border border-line bg-bg">
80− {names.map((name) => {
81− const seenCheck = seen.find((check) => check.name.toLowerCase() === name.toLowerCase());
82− const isAdded = known(name, added) && !known(name, required);
83− return (
84− <li key={name} className="flex items-start gap-2 px-3 py-2.5">
85− <CheckboxOption
86− className="min-w-0 grow"
87− name="requiredChecks"
88− value={name}
89− defaultChecked={known(name, required) || isAdded}
90− disabled={disabled}
91− label={<span className="font-mono text-[0.8125rem]">{name}</span>}
92− description={about(seenCheck, mergeQueue)}
93− />
94− {isAdded && (
95− <button
96− type="button"
97− aria-label={`Remove ${name}`}
98− onClick={() => setAdded(added.filter((other) => other !== name))}
99− className="rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
100− >
101− <X size={13} />
102− </button>
103− )}
104− </li>
105− );
106− })}
107− </ul>
108− )}
109− <div className="mt-3 flex flex-wrap gap-2">
110− <input
111− value={typed}
112− onChange={(event) => setTyped(event.target.value)}
113− onKeyDown={(event) => {
114− if (event.key === "Enter") {
115− event.preventDefault();
116− add();
117− }
118− }}
119− disabled={disabled}
120− maxLength={100}
121− placeholder="Another check, by name"
122− aria-label="Require another check, by name"
123− className="min-w-0 grow rounded-md border border-line bg-bg px-3 py-1.5 font-mono text-sm placeholder:font-sans placeholder:text-faint focus:border-line-strong focus:outline-none sm:max-w-xs"
124− />
125− <button
126− type="button"
127− onClick={add}
128− disabled={disabled || typed.trim() === ""}
129− className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 hover:border-line-strong hover:bg-raised disabled:opacity-50"
130− >
131− <Plus size={13} />
132− Add
133− </button>
134− </div>
135− </div>
136− );
137−}
+1342−0
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+ */
8+import {
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";
23+import { type ReactNode, useMemo, useRef, useState } from "react";
24+import { Form, Link } from "react-router";
25+
26+import type {
27+ AppliesTo,
28+ BypassActor,
29+ BypassActorKind,
30+ ConfidenceLevel,
31+ EffectiveRules,
32+ Enforcement,
33+ Evaluation,
34+ EvaluationPage,
35+ Level,
36+ MergeMethod,
37+ PatternOperator,
38+ RuleEntry,
39+ RuleType,
40+ Ruleset,
41+ RulesetSpec,
42+ SeenCheck,
43+ Violation,
44+ Weekday,
45+} from "@g1t/contracts";
46+
47+import { cn } from "../lib/cn";
48+import {
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";
62+import { Button, ErrorText, SubmitButton, TimeAgo } from "./ui";
63+import { Badge } from "./ui/badge";
64+import { CheckboxOption } from "./ui/checkbox";
65+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
66+import { Input } from "./ui/input";
67+import { RadioGroup, RadioOption } from "./ui/radio-group";
68+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
69+import { Switch } from "./ui/switch";
70+
71+// --- Small pieces -----------------------------------------------------------
72+
73+const ENFORCEMENT: Record<Enforcement, { label: string; about: string; tone: "accent" | "info" | "neutral" }> = {
74+ active: { label: "Active", about: "Its rules hold: what breaks them is refused.", tone: "accent" },
75+ evaluate: { label: "Evaluate", about: "A dry run: nothing is refused, and what would have been is recorded in Insights.", tone: "info" },
76+ disabled: { label: "Disabled", about: "Kept, but not evaluated.", tone: "neutral" },
77+};
78+
79+export function EnforcementBadge({ enforcement }: { enforcement: Enforcement }) {
80+ const info = ENFORCEMENT[enforcement];
81+ return <Badge tone={info.tone}>{info.label}</Badge>;
82+}
83+
84+/** A list of patterns or names, as removable chips and a field to add one. */
85+export function PatternList({
86+ values,
87+ onChange,
88+ placeholder,
89+ quick = [],
90+ disabled,
91+ label,
92+ mono = true,
93+}: {
94+ values: string[];
95+ onChange: (values: string[]) => void;
96+ placeholder: string;
97+ quick?: { value: string; label: string }[];
98+ disabled?: boolean;
99+ label: string;
100+ mono?: boolean;
101+}) {
102+ const [typed, setTyped] = useState("");
103+ const add = (value: string) => {
104+ const trimmed = value.trim();
105+ if (!trimmed || values.includes(trimmed)) return;
106+ onChange([...values, trimmed]);
107+ setTyped("");
108+ };
109+ return (
110+ <div className="space-y-2">
111+ {values.length > 0 && (
112+ <ul className="flex flex-wrap gap-1.5" aria-label={label}>
113+ {values.map((value) => (
114+ <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">
115+ <span className={cn("truncate", mono && !value.startsWith("~") && "font-mono text-[0.8125rem]")}>{patternLabel(value)}</span>
116+ {!disabled && (
117+ <button
118+ type="button"
119+ aria-label={`Remove ${patternLabel(value)}`}
120+ className="rounded p-0.5 text-faint hover:bg-surface hover:text-fg"
121+ onClick={() => onChange(values.filter((other) => other !== value))}
122+ >
123+ <X size={13} />
124+ </button>
125+ )}
126+ </li>
127+ ))}
128+ </ul>
129+ )}
130+ {!disabled && (
131+ <div className="flex flex-wrap items-center gap-2">
132+ <Input
133+ value={typed}
134+ aria-label={label}
135+ placeholder={placeholder}
136+ className="w-56 max-w-full font-mono text-[0.8125rem]"
137+ onChange={(event) => setTyped(event.target.value)}
138+ onKeyDown={(event) => {
139+ if (event.key === "Enter") {
140+ event.preventDefault();
141+ add(typed);
142+ }
143+ }}
144+ />
145+ <Button type="button" variant="quiet" onClick={() => add(typed)} disabled={!typed.trim()}>
146+ <Plus size={14} /> Add
147+ </Button>
148+ {quick
149+ .filter((option) => !values.includes(option.value))
150+ .map((option) => (
151+ <button
152+ key={option.value}
153+ type="button"
154+ className="rounded-full border border-dashed border-line px-2.5 py-1 text-xs text-muted hover:border-line-strong hover:text-fg"
155+ onClick={() => add(option.value)}
156+ >
157+ + {option.label}
158+ </button>
159+ ))}
160+ </div>
161+ )}
162+ </div>
163+ );
164+}
165+
166+function Choice<T extends string>({
167+ value,
168+ options,
169+ onChange,
170+ label,
171+ disabled,
172+ className,
173+}: {
174+ value: T;
175+ options: { value: T; label: string }[];
176+ onChange: (value: T) => void;
177+ label: string;
178+ disabled?: boolean;
179+ className?: string;
180+}) {
181+ return (
182+ <Select value={value} onValueChange={(next) => onChange(next as T)} disabled={disabled}>
183+ <SelectTrigger size="sm" aria-label={label} className={cn("w-auto min-w-28", className)}>
184+ <SelectValue />
185+ </SelectTrigger>
186+ <SelectContent>
187+ {options.map((option) => (
188+ <SelectItem key={option.value} value={option.value}>
189+ {option.label}
190+ </SelectItem>
191+ ))}
192+ </SelectContent>
193+ </Select>
194+ );
195+}
196+
197+function Toggle({
198+ checked,
199+ onChange,
200+ title,
201+ about,
202+ disabled,
203+}: {
204+ checked: boolean;
205+ onChange: (checked: boolean) => void;
206+ title: string;
207+ about?: ReactNode;
208+ disabled?: boolean;
209+}) {
210+ return (
211+ <label className="flex cursor-pointer items-start justify-between gap-4 py-1.5">
212+ <span className="min-w-0">
213+ <span className="block text-sm text-fg">{title}</span>
214+ {about && <span className="mt-0.5 block text-xs text-muted">{about}</span>}
215+ </span>
216+ <Switch size="sm" checked={checked} onCheckedChange={(next) => onChange(next === true)} disabled={disabled} className="mt-0.5" aria-label={title} />
217+ </label>
218+ );
219+}
220+
221+function NumberField({
222+ value,
223+ onChange,
224+ label,
225+ min,
226+ max,
227+ step = 1,
228+ suffix,
229+ disabled,
230+}: {
231+ value: number;
232+ onChange: (value: number) => void;
233+ label: string;
234+ min: number;
235+ max: number;
236+ step?: number;
237+ suffix?: string;
238+ disabled?: boolean;
239+}) {
240+ return (
241+ <label className="flex items-center justify-between gap-4 py-1.5">
242+ <span className="text-sm text-fg">{label}</span>
243+ <span className="flex items-center gap-2">
244+ <Input
245+ type="number"
246+ min={min}
247+ max={max}
248+ step={step}
249+ value={Number.isFinite(value) ? value : ""}
250+ disabled={disabled}
251+ className="w-24 text-right"
252+ onChange={(event) => onChange(Number(event.target.value))}
253+ />
254+ {suffix && <span className="text-sm text-muted">{suffix}</span>}
255+ </span>
256+ </label>
257+ );
258+}
259+
260+// --- Rule parameters ----------------------------------------------------------
261+
262+const OPERATORS: { value: PatternOperator; label: string }[] = [
263+ { value: "starts_with", label: "Starts with" },
264+ { value: "ends_with", label: "Ends with" },
265+ { value: "contains", label: "Contains" },
266+ { value: "regex", label: "Matches regex" },
267+];
268+const LEVELS: { value: ConfidenceLevel; label: string }[] = [
269+ { value: "low", label: "Low" },
270+ { value: "medium", label: "Medium" },
271+ { value: "high", label: "High" },
272+];
273+const DAYS: Weekday[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
274+const DAY_LABEL: Record<Weekday, string> = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" };
275+
276+/** An RFC 3339 time as a datetime-local value, in the viewer's zone. */
277+function toLocal(iso: string | null | undefined): string {
278+ if (!iso) return "";
279+ const date = new Date(iso);
280+ if (Number.isNaN(date.getTime())) return "";
281+ const offset = date.getTimezoneOffset() * 60_000;
282+ return new Date(date.getTime() - offset).toISOString().slice(0, 16);
283+}
284+
285+function fromLocal(value: string): string | null {
286+ if (!value) return null;
287+ const date = new Date(value);
288+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
289+}
290+
291+type Periods = { start: string; end?: string | null; reason: string }[];
292+
293+function PeriodList({
294+ periods,
295+ onChange,
296+ label,
297+ disabled,
298+ allowOpen,
299+}: {
300+ periods: Periods;
301+ onChange: (periods: Periods) => void;
302+ label: string;
303+ disabled?: boolean;
304+ allowOpen?: boolean;
305+}) {
306+ return (
307+ <div className="space-y-2">
308+ {periods.map((period, index) => (
309+ <div key={index} className="flex flex-wrap items-center gap-2 rounded-lg border border-line p-2">
310+ <Input
311+ type="datetime-local"
312+ aria-label={`${label} ${index + 1} starts`}
313+ value={toLocal(period.start)}
314+ disabled={disabled}
315+ className="w-auto"
316+ onChange={(event) => onChange(periods.map((one, at) => (at === index ? { ...one, start: fromLocal(event.target.value) ?? one.start } : one)))}
317+ />
318+ <span className="text-sm text-muted">to</span>
319+ <Input
320+ type="datetime-local"
321+ aria-label={`${label} ${index + 1} ends`}
322+ value={toLocal(period.end)}
323+ disabled={disabled}
324+ className="w-auto"
325+ onChange={(event) => onChange(periods.map((one, at) => (at === index ? { ...one, end: fromLocal(event.target.value) } : one)))}
326+ />
327+ <Input
328+ aria-label={`${label} ${index + 1} reason`}
329+ placeholder="Why"
330+ value={period.reason}
331+ disabled={disabled}
332+ className="w-40 grow"
333+ onChange={(event) => onChange(periods.map((one, at) => (at === index ? { ...one, reason: event.target.value } : one)))}
334+ />
335+ {!disabled && (
336+ <button type="button" aria-label={`Remove ${label.toLowerCase()} ${index + 1}`} className="rounded p-1 text-faint hover:text-fg" onClick={() => onChange(periods.filter((_, at) => at !== index))}>
337+ <X size={14} />
338+ </button>
339+ )}
340+ </div>
341+ ))}
342+ {!disabled && (
343+ <div className="flex flex-wrap gap-2">
344+ <Button
345+ type="button"
346+ variant="quiet"
347+ onClick={() => onChange([...periods, { start: new Date().toISOString(), end: new Date(Date.now() + 86_400_000).toISOString(), reason: "" }])}
348+ >
349+ <Plus size={14} /> {label}
350+ </Button>
351+ {allowOpen && (
352+ <Button type="button" variant="quiet" onClick={() => onChange([...periods, { start: new Date().toISOString(), end: null, reason: "Incident" }])}>
353+ <ShieldAlert size={14} /> Freeze now, until lifted
354+ </Button>
355+ )}
356+ </div>
357+ )}
358+ </div>
359+ );
360+}
361+
362+/** The parameters of one rule, as fields. */
363+function Parameters({
364+ entry,
365+ onChange,
366+ seen,
367+ disabled,
368+}: {
369+ entry: RuleEntry;
370+ onChange: (entry: RuleEntry) => void;
371+ seen: SeenCheck[];
372+ disabled?: boolean;
373+}) {
374+ // Each case narrows `entry` and writes back its own parameters.
375+ const set = (parameters: object) => onChange({ ...entry, parameters } as RuleEntry);
376+ switch (entry.type) {
377+ case "pull_request": {
378+ const p = entry.parameters;
379+ const methods: MergeMethod[] = ["merge", "squash", "rebase"];
380+ return (
381+ <div className="divide-y divide-line">
382+ <NumberField label="Required approvals" min={0} max={10} value={p.required_approvals} disabled={disabled} onChange={(value) => set({ ...p, required_approvals: value })} />
383+ <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 })} />
384+ <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 })} />
385+ <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 })} />
386+ <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 })} />
387+ {p.allow_direct_pushes && (
388+ <Toggle
389+ title="Still allow pushes straight to the branch"
390+ about="Kept from branch protection that did not require pull requests. Turn it off to require them."
391+ checked={p.allow_direct_pushes}
392+ disabled={disabled}
393+ onChange={(value) => set({ ...p, allow_direct_pushes: value })}
394+ />
395+ )}
396+ <div className="py-2">
397+ <p className="text-sm text-fg">Allowed merge methods</p>
398+ <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>
399+ <div className="mt-2 flex flex-wrap gap-4">
400+ {methods.map((method) => (
401+ <CheckboxOption
402+ key={method}
403+ label={method[0]!.toUpperCase() + method.slice(1)}
404+ checked={p.allowed_merge_methods.includes(method)}
405+ disabled={disabled}
406+ onCheckedChange={(checked) =>
407+ set({
408+ ...p,
409+ allowed_merge_methods: checked
410+ ? [...p.allowed_merge_methods, method]
411+ : p.allowed_merge_methods.filter((other) => other !== method),
412+ })
413+ }
414+ />
415+ ))}
416+ </div>
417+ </div>
418+ </div>
419+ );
420+ }
421+ case "required_status_checks": {
422+ const p = entry.parameters;
423+ const names = p.checks.map((check) => check.context);
424+ const suggestions = seen.map((check) => check.name).filter((name) => !names.some((have) => have.toLowerCase() === name.toLowerCase()));
425+ return (
426+ <div className="space-y-3">
427+ <div>
428+ <p className="mb-2 text-sm text-fg">Checks</p>
429+ <ul className="space-y-1.5">
430+ {p.checks.map((check, index) => (
431+ <li key={check.context} className="flex flex-wrap items-center gap-2 rounded-lg border border-line px-2.5 py-1.5">
432+ <span className="grow font-mono text-[0.8125rem]">{check.context}</span>
433+ <Choice
434+ label={`Where ${check.context} comes from`}
435+ value={check.integration ?? "any"}
436+ disabled={disabled}
437+ options={[
438+ { value: "any", label: "Any source" },
439+ { value: "actions", label: "Workflows" },
440+ { value: "deployments", label: "Deployments" },
441+ { value: "security", label: "Security" },
442+ { value: "g1t", label: "g1t" },
443+ ]}
444+ onChange={(value) =>
445+ set({
446+ ...p,
447+ checks: p.checks.map((one, at) =>
448+ at === index ? (value === "any" ? { context: one.context } : { context: one.context, integration: value }) : one,
449+ ),
450+ })
451+ }
452+ />
453+ {!disabled && (
454+ <button type="button" aria-label={`Stop requiring ${check.context}`} className="rounded p-1 text-faint hover:text-fg" onClick={() => set({ ...p, checks: p.checks.filter((_, at) => at !== index) })}>
455+ <X size={14} />
456+ </button>
457+ )}
458+ </li>
459+ ))}
460+ </ul>
461+ <div className="mt-2">
462+ <PatternList
463+ label="Add a required check"
464+ values={[]}
465+ placeholder="CI"
466+ mono={false}
467+ disabled={disabled}
468+ quick={suggestions.slice(0, 8).map((name) => ({ value: name, label: name }))}
469+ onChange={(added) => set({ ...p, checks: [...p.checks, ...added.map((context) => ({ context }))] })}
470+ />
471+ </div>
472+ </div>
473+ <div className="divide-y divide-line">
474+ <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 })} />
475+ <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 })} />
476+ </div>
477+ <div>
478+ <p className="text-sm text-fg">Only when these paths change</p>
479+ <p className="mb-2 text-xs text-muted">Empty: always required.</p>
480+ <PatternList label="Paths" values={p.paths} placeholder="infra/**" disabled={disabled} onChange={(paths) => set({ ...p, paths })} />
481+ </div>
482+ </div>
483+ );
484+ }
485+ case "merge_queue": {
486+ const p = entry.parameters;
487+ return (
488+ <div className="divide-y divide-line">
489+ <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 })} />
490+ <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 })} />
491+ <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 })} />
492+ <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 })} />
493+ </div>
494+ );
495+ }
496+ case "required_deployments":
497+ return (
498+ <PatternList
499+ label="Environments"
500+ values={entry.parameters.environments}
501+ placeholder="preview, or a project's slug"
502+ quick={[{ value: "preview", label: "preview" }]}
503+ disabled={disabled}
504+ onChange={(environments) => set({ environments })}
505+ />
506+ );
507+ case "commit_message_pattern":
508+ case "commit_author_email_pattern":
509+ case "committer_email_pattern":
510+ case "branch_name_pattern":
511+ case "tag_name_pattern": {
512+ const p = entry.parameters;
513+ return (
514+ <div className="space-y-2">
515+ <div className="flex flex-wrap items-center gap-2">
516+ <Choice label="How it compares" value={p.operator} options={OPERATORS} disabled={disabled} onChange={(operator) => set({ ...p, operator })} />
517+ <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 })} />
518+ </div>
519+ <div className="flex flex-wrap items-center gap-4">
520+ <CheckboxOption label="Must not match" checked={p.negate} disabled={disabled} onCheckedChange={(checked) => set({ ...p, negate: checked === true })} />
521+ <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 })} />
522+ </div>
523+ {p.operator === "regex" && <p className="text-xs text-muted">Runs on a linear-time engine: no look-around or back-references.</p>}
524+ </div>
525+ );
526+ }
527+ case "file_path_restriction":
528+ return (
529+ <PatternList
530+ label="Restricted paths"
531+ values={entry.parameters.restricted_file_paths}
532+ placeholder=".g1t/workflows/**"
533+ quick={[
534+ { value: ".g1t/workflows/**", label: "Workflows" },
535+ { value: "CODEOWNERS", label: "CODEOWNERS" },
536+ ]}
537+ disabled={disabled}
538+ onChange={(restricted_file_paths) => set({ restricted_file_paths })}
539+ />
540+ );
541+ case "file_extension_restriction":
542+ return (
543+ <PatternList
544+ label="Restricted extensions"
545+ values={entry.parameters.restricted_file_extensions}
546+ placeholder=".exe"
547+ quick={[
548+ { value: ".exe", label: ".exe" },
549+ { value: ".zip", label: ".zip" },
550+ ]}
551+ disabled={disabled}
552+ onChange={(restricted_file_extensions) => set({ restricted_file_extensions })}
553+ />
554+ );
555+ case "max_file_size":
556+ 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 })} />;
557+ case "max_file_path_length":
558+ 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 })} />;
559+ case "max_files_changed":
560+ 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 })} />;
561+ case "confidence_threshold": {
562+ const p = entry.parameters;
563+ return (
564+ <div className="divide-y divide-line">
565+ <label className="flex items-center justify-between gap-4 py-1.5">
566+ <span className="text-sm text-fg">Changes rated below</span>
567+ <Choice label="Lowest confidence that merges without people" value={p.minimum} options={LEVELS} disabled={disabled} onChange={(minimum) => set({ ...p, minimum })} />
568+ </label>
569+ <NumberField label="need approvals from people" min={1} max={10} value={p.required_approvals} disabled={disabled} onChange={(required_approvals) => set({ ...p, required_approvals })} />
570+ </div>
571+ );
572+ }
573+ case "cost_cap":
574+ return <NumberField label="Most agents may spend on one pull request" min={0.5} max={10000} step={0.5} suffix="USD" value={entry.parameters.max_usd} disabled={disabled} onChange={(max_usd) => set({ max_usd })} />;
575+ case "path_review": {
576+ const p = entry.parameters;
577+ return (
578+ <div className="space-y-2">
579+ <PatternList label="Sensitive paths" values={p.paths} placeholder="infra/**" disabled={disabled} onChange={(paths) => set({ ...p, paths })} />
580+ <NumberField label="Approvals from people" min={1} max={10} value={p.required_approvals} disabled={disabled} onChange={(required_approvals) => set({ ...p, required_approvals })} />
581+ <label className="flex items-center justify-between gap-4 py-1.5">
582+ <span className="text-sm text-fg">From the team</span>
583+ <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 })} />
584+ </label>
585+ </div>
586+ );
587+ }
588+ case "merge_window": {
589+ const p = entry.parameters;
590+ return (
591+ <div className="space-y-4">
592+ <label className="flex items-center justify-between gap-4">
593+ <span className="text-sm text-fg">Time zone, as an offset from UTC</span>
594+ <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 })} />
595+ </label>
596+ <div>
597+ <p className="text-sm text-fg">Weekly hours when merging is open</p>
598+ <p className="mb-2 text-xs text-muted">None: open whenever no freeze covers the moment.</p>
599+ <div className="space-y-2">
600+ {p.windows.map((window, index) => (
601+ <div key={index} className="flex flex-wrap items-center gap-2 rounded-lg border border-line p-2">
602+ <div className="flex flex-wrap gap-1">
603+ {DAYS.map((day) => {
604+ const on = window.days.includes(day);
605+ return (
606+ <button
607+ key={day}
608+ type="button"
609+ aria-pressed={on}
610+ disabled={disabled}
611+ 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")}
612+ onClick={() =>
613+ set({
614+ ...p,
615+ windows: p.windows.map((one, at) =>
616+ at === index ? { ...one, days: on ? one.days.filter((other) => other !== day) : DAYS.filter((d) => d === day || one.days.includes(d)) } : one,
617+ ),
618+ })
619+ }
620+ >
621+ {DAY_LABEL[day]}
622+ </button>
623+ );
624+ })}
625+ </div>
626+ <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)) })} />
627+ <span className="text-sm text-muted">to</span>
628+ <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)) })} />
629+ {!disabled && (
630+ <button type="button" aria-label={`Remove window ${index + 1}`} className="rounded p-1 text-faint hover:text-fg" onClick={() => set({ ...p, windows: p.windows.filter((_, at) => at !== index) })}>
631+ <X size={14} />
632+ </button>
633+ )}
634+ </div>
635+ ))}
636+ {!disabled && (
637+ <Button type="button" variant="quiet" onClick={() => set({ ...p, windows: [...p.windows, { days: ["mon", "tue", "wed", "thu", "fri"], start: "09:00", end: "17:00" }] })}>
638+ <Plus size={14} /> Weekly hours
639+ </Button>
640+ )}
641+ </div>
642+ </div>
643+ <div>
644+ <p className="mb-2 text-sm text-fg">Freezes: merging waits</p>
645+ <PeriodList label="Freeze" periods={p.freezes} allowOpen disabled={disabled} onChange={(freezes) => set({ ...p, freezes })} />
646+ </div>
647+ <div>
648+ <p className="mb-2 text-sm text-fg">Exceptions: merging is open whatever else says</p>
649+ <PeriodList label="Exception" periods={p.exceptions} disabled={disabled} onChange={(exceptions) => set({ ...p, exceptions })} />
650+ </div>
651+ </div>
652+ );
653+ }
654+ case "agent_auto_merge": {
655+ const p = entry.parameters;
656+ return (
657+ <div className="divide-y divide-line">
658+ <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 })} />
659+ <label className="flex items-center justify-between gap-4 py-1.5">
660+ <span className="text-sm text-fg">Only at this confidence or higher</span>
661+ <Choice
662+ label="Lowest confidence that merges by itself"
663+ value={p.minimum_confidence ?? "any"}
664+ options={[{ value: "any", label: "Any" }, ...LEVELS]}
665+ disabled={disabled}
666+ onChange={(value) => set({ ...p, minimum_confidence: value === "any" ? null : value })}
667+ />
668+ </label>
669+ </div>
670+ );
671+ }
672+ default:
673+ return null;
674+ }
675+}
676+
677+const APPLIES: { value: AppliesTo; label: string }[] = [
678+ { value: "everyone", label: "Everyone" },
679+ { value: "agents", label: "Agents' changes" },
680+ { value: "people", label: "People's changes" },
681+];
682+
683+function RuleCard({
684+ entry,
685+ onChange,
686+ onRemove,
687+ seen,
688+ disabled,
689+}: {
690+ entry: RuleEntry;
691+ onChange: (entry: RuleEntry) => void;
692+ onRemove: () => void;
693+ seen: SeenCheck[];
694+ disabled?: boolean;
695+}) {
696+ const info = ruleInfo(entry.type);
697+ const agentsOnly = info?.group === "agents";
698+ return (
699+ <li className="rounded-xl border border-line bg-surface">
700+ <div className="flex flex-wrap items-start justify-between gap-3 p-4">
701+ <div className="min-w-0 grow basis-60">
702+ <p className="flex items-center gap-2 text-sm font-medium">
703+ {agentsOnly && <Bot size={14} className="text-accent" />}
704+ {info?.label ?? entry.type}
705+ </p>
706+ <p className="mt-0.5 text-sm text-muted">{info?.about}</p>
707+ </div>
708+ <div className="flex items-center gap-2">
709+ <Choice label={`Whose changes ${info?.label ?? entry.type} holds for`} value={entry.applies_to} options={APPLIES} disabled={disabled} onChange={(applies_to) => onChange({ ...entry, applies_to })} />
710+ {!disabled && (
711+ <button type="button" aria-label={`Remove ${info?.label ?? entry.type}`} className="rounded-md p-1.5 text-faint hover:bg-bg hover:text-danger" onClick={onRemove}>
712+ <Trash2 size={15} />
713+ </button>
714+ )}
715+ </div>
716+ </div>
717+ {Object.keys(entry.parameters).length > 0 && (
718+ <div className="border-t border-line px-4 py-3">
719+ <Parameters entry={entry} onChange={onChange} seen={seen} disabled={disabled} />
720+ </div>
721+ )}
722+ </li>
723+ );
724+}
725+
726+// --- Bypass list --------------------------------------------------------------
727+
728+const KINDS: { value: BypassActorKind; label: string; placeholder: string }[] = [
729+ { value: "role", label: "Role", placeholder: "admin" },
730+ { value: "team", label: "Team", placeholder: "workspace/team" },
731+ { value: "user", label: "Person", placeholder: "username" },
732+ { value: "token", label: "Token", placeholder: "token id, or workspace" },
733+ { value: "g1t", label: "g1t", placeholder: "" },
734+];
735+
736+function BypassList({ actors, onChange, disabled }: { actors: BypassActor[]; onChange: (actors: BypassActor[]) => void; disabled?: boolean }) {
737+ const update = (index: number, change: Partial<BypassActor>) => onChange(actors.map((one, at) => (at === index ? { ...one, ...change } : one)));
738+ const hasG1t = actors.some((actor) => actor.kind === "g1t");
739+ return (
740+ <div className="space-y-2">
741+ {actors.length === 0 && <p className="text-sm text-muted">Nobody: these rules hold for everyone, people and agents alike, g1t included.</p>}
742+ <ul className="space-y-2">
743+ {actors.map((actor, index) => (
744+ <li key={index} className="flex flex-wrap items-center gap-2 rounded-lg border border-line p-2">
745+ <Choice label="Who" value={actor.kind} options={KINDS} disabled={disabled} onChange={(kind) => update(index, { kind, value: kind === "role" ? "admin" : "" })} />
746+ {actor.kind === "role" ? (
747+ <Choice
748+ label="Role"
749+ value={actor.value || "admin"}
750+ disabled={disabled}
751+ options={[
752+ { value: "write", label: "Write and up" },
753+ { value: "maintain", label: "Maintain and up" },
754+ { value: "admin", label: "Admin" },
755+ { value: "owner", label: "Workspace owners" },
756+ ]}
757+ onChange={(value) => update(index, { value })}
758+ />
759+ ) : actor.kind === "g1t" ? (
760+ <span className="grow text-sm text-muted">g1t's agents and g1t itself (the merge queue, security updates)</span>
761+ ) : (
762+ <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 })} />
763+ )}
764+ <Choice
765+ label="When"
766+ value={actor.mode}
767+ disabled={disabled}
768+ options={[
769+ { value: "always", label: "Always" },
770+ { value: "pull_requests", label: "Pull requests only" },
771+ ]}
772+ onChange={(mode) => update(index, { mode })}
773+ />
774+ {!disabled && (
775+ <button type="button" aria-label={`Remove ${describeBypassActor(actor)}`} className="rounded p-1 text-faint hover:text-fg" onClick={() => onChange(actors.filter((_, at) => at !== index))}>
776+ <X size={14} />
777+ </button>
778+ )}
779+ </li>
780+ ))}
781+ </ul>
782+ {hasG1t && (
783+ <p className="flex items-start gap-2 text-xs text-warn">
784+ <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.
785+ </p>
786+ )}
787+ {!disabled && (
788+ <Button type="button" variant="quiet" onClick={() => onChange([...actors, { kind: "role", value: "admin", mode: "pull_requests" }])}>
789+ <Plus size={14} /> Add a bypass
790+ </Button>
791+ )}
792+ </div>
793+ );
794+}
795+
796+// --- The form -----------------------------------------------------------------
797+
798+function Block({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) {
799+ return (
800+ <section className="grid gap-x-10 gap-y-3 border-t border-line pt-6 lg:grid-cols-[14rem_1fr]">
801+ <div>
802+ <h2 className="font-medium">{title}</h2>
803+ <p className="mt-1 text-sm text-muted">{about}</p>
804+ </div>
805+ <div className="min-w-0 space-y-3">{children}</div>
806+ </section>
807+ );
808+}
809+
810+/** Downloads `ruleset` as a JSON file. */
811+function download(ruleset: RulesetSpec) {
812+ const blob = new Blob([JSON.stringify(exportRuleset(ruleset), null, 2) + "\n"], { type: "application/json" });
813+ const url = URL.createObjectURL(blob);
814+ const link = document.createElement("a");
815+ link.href = url;
816+ link.download = `${(ruleset.name || "ruleset").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "ruleset"}.json`;
817+ link.click();
818+ URL.revokeObjectURL(url);
819+}
820+
821+/**
822+ * Creating or changing a ruleset. The ruleset is posted whole, as JSON in
823+ * `ruleset`, with `intent` `save`; deleting posts `intent` `delete`.
824+ */
825+export function RulesetForm({
826+ initial,
827+ level,
828+ existing,
829+ seen = [],
830+ editable,
831+ error,
832+ backHref,
833+ repositoryLabel,
834+}: {
835+ initial: RulesetSpec;
836+ level: Level;
837+ /** The ruleset being changed; absent for a new one. */
838+ existing?: Ruleset;
839+ /** Check names reported lately, to require. */
840+ seen?: SeenCheck[];
841+ editable: boolean;
842+ error?: string | null;
843+ backHref: string;
844+ /** For a repository's: its name, for the hint about the default branch. */
845+ repositoryLabel?: string;
846+}) {
847+ const [spec, setSpec] = useState<RulesetSpec>(initial);
848+ const [imported, setImported] = useState<string | null>(null);
849+ const file = useRef<HTMLInputElement>(null);
850+ const disabled = !editable;
851+ const update = (change: Partial<RulesetSpec>) => setSpec((current) => ({ ...current, ...change }));
852+ const addable = RULES.filter(
853+ (info) => info.targets.includes(spec.target) && (info.repeatable || !spec.rules.some((rule) => rule.type === info.type && rule.applies_to === "everyone")),
854+ );
855+ const fromBranchProtection = existing?.source === "branch_protection";
856+ return (
857+ <>
858+ <Form method="post" className="space-y-6">
859+ <input type="hidden" name="intent" value="save" />
860+ <input type="hidden" name="ruleset" value={JSON.stringify(spec)} />
861+ {fromBranchProtection && (
862+ <p className="flex items-start gap-2 rounded-lg border border-info/30 bg-info/5 p-3 text-sm text-muted">
863+ <ShieldCheck size={15} className="mt-0.5 shrink-0 text-info" />
864+ Made from this repository's branch protection settings, holding exactly what they held. The pull request, status check and merge
865+ queue rules here are also what Settings → Branches and the settings API read and write.
866+ </p>
867+ )}
868+ <section className="grid gap-4 md:grid-cols-[1fr_auto]">
869+ <label className="block">
870+ <span className="mb-1.5 block text-sm font-medium text-muted">Name</span>
871+ <Input name="name" value={spec.name} disabled={disabled} maxLength={100} placeholder="Protect main" required onChange={(event) => update({ name: event.target.value })} />
872+ </label>
873+ <div>
874+ <span className="mb-1.5 block text-sm font-medium text-muted">Targets</span>
875+ <div className="flex rounded-md border border-line p-0.5" role="radiogroup" aria-label="Targets">
876+ {(["branch", "tag"] as const).map((target) => (
877+ <button
878+ key={target}
879+ type="button"
880+ role="radio"
881+ aria-checked={spec.target === target}
882+ disabled={disabled}
883+ 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")}
884+ onClick={() =>
885+ update({
886+ target,
887+ conditions: { ...spec.conditions, ref_name: { include: target === "tag" ? [] : [DEFAULT_BRANCH], exclude: [] } },
888+ rules: spec.rules.filter((rule) => ruleInfo(rule.type)?.targets.includes(target)),
889+ })
890+ }
891+ >
892+ {target === "branch" ? <GitBranch size={14} /> : <Tag size={14} />}
893+ {target === "branch" ? "Branches" : "Tags"}
894+ </button>
895+ ))}
896+ </div>
897+ </div>
898+ </section>
899+
900+ <Block title="Enforcement" about="Try a ruleset in evaluate first: Insights shows what it would have refused.">
901+ <RadioGroup value={spec.enforcement} onValueChange={(value) => update({ enforcement: value as Enforcement })} disabled={disabled} className="grid gap-3 sm:grid-cols-3">
902+ {(Object.keys(ENFORCEMENT) as Enforcement[]).map((enforcement) => (
903+ <RadioOption key={enforcement} value={enforcement} label={ENFORCEMENT[enforcement].label} description={ENFORCEMENT[enforcement].about} />
904+ ))}
905+ </RadioGroup>
906+ </Block>
907+
908+ <Block
909+ title={spec.target === "branch" ? "Branches" : "Tags"}
910+ about={
911+ <>
912+ Names it holds for: fnmatch patterns, <code>*</code> within a segment and <code>**</code> across them.
913+ {repositoryLabel ? ` ${repositoryLabel}'s default branch is matched by name too.` : ""}
914+ </>
915+ }
916+ >
917+ <div>
918+ <p className="mb-2 text-sm text-fg">Include</p>
919+ <PatternList
920+ label="Include"
921+ values={spec.conditions.ref_name.include}
922+ placeholder={spec.target === "branch" ? "release/*" : "v*"}
923+ quick={
924+ spec.target === "branch"
925+ ? [
926+ { value: DEFAULT_BRANCH, label: "Default branch" },
927+ { value: ALL, label: "All branches" },
928+ ]
929+ : [{ value: ALL, label: "All tags" }]
930+ }
931+ disabled={disabled}
932+ onChange={(include) => update({ conditions: { ...spec.conditions, ref_name: { ...spec.conditions.ref_name, include } } })}
933+ />
934+ </div>
935+ <div>
936+ <p className="mb-2 text-sm text-fg">Exclude</p>
937+ <PatternList
938+ label="Exclude"
939+ values={spec.conditions.ref_name.exclude}
940+ placeholder={spec.target === "branch" ? "dependabot/**" : "v*-rc*"}
941+ disabled={disabled}
942+ onChange={(exclude) => update({ conditions: { ...spec.conditions, ref_name: { ...spec.conditions.ref_name, exclude } } })}
943+ />
944+ </div>
945+ </Block>
946+
947+ {level === "workspace" && spec.conditions.repository && (
948+ <Block title="Repositories" about="Which of the workspace's repositories it holds in.">
949+ <div>
950+ <p className="mb-2 text-sm text-fg">Names to include</p>
951+ <PatternList
952+ label="Repositories to include"
953+ values={spec.conditions.repository.include}
954+ placeholder="api-*"
955+ quick={[{ value: ALL, label: "All repositories" }]}
956+ disabled={disabled}
957+ onChange={(include) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, include } } })}
958+ />
959+ </div>
960+ <div>
961+ <p className="mb-2 text-sm text-fg">Names to exclude</p>
962+ <PatternList
963+ label="Repositories to exclude"
964+ values={spec.conditions.repository.exclude}
965+ placeholder="sandbox-*"
966+ disabled={disabled}
967+ onChange={(exclude) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, exclude } } })}
968+ />
969+ </div>
970+ <label className="flex items-center justify-between gap-4">
971+ <span className="text-sm text-fg">Visibility</span>
972+ <Choice
973+ label="Visibility"
974+ value={spec.conditions.repository.visibility}
975+ disabled={disabled}
976+ options={[
977+ { value: "any", label: "Public and private" },
978+ { value: "public", label: "Public only" },
979+ { value: "private", label: "Private only" },
980+ ]}
981+ onChange={(visibility) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, visibility } } })}
982+ />
983+ </label>
984+ <div>
985+ <p className="text-sm text-fg">Topics</p>
986+ <p className="mb-2 text-xs text-muted">Only repositories carrying one of these. Empty: any.</p>
987+ <PatternList
988+ label="Topics"
989+ values={spec.conditions.repository.topics}
990+ placeholder="payments"
991+ mono={false}
992+ disabled={disabled}
993+ onChange={(topics) => update({ conditions: { ...spec.conditions, repository: { ...spec.conditions.repository!, topics } } })}
994+ />
995+ </div>
996+ </Block>
997+ )}
998+
999+ <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.">
1000+ <BypassList actors={spec.bypass_actors} disabled={disabled} onChange={(bypass_actors) => update({ bypass_actors })} />
1001+ </Block>
1002+
1003+ <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.">
1004+ {spec.rules.length === 0 && <p className="text-sm text-muted">No rules yet.</p>}
1005+ <ul className="space-y-3">
1006+ {spec.rules.map((entry, index) => (
1007+ <RuleCard
1008+ key={`${entry.type}-${index}`}
1009+ entry={entry}
1010+ seen={seen}
1011+ disabled={disabled}
1012+ onChange={(changed) => update({ rules: spec.rules.map((one, at) => (at === index ? changed : one)) })}
1013+ onRemove={() => update({ rules: spec.rules.filter((_, at) => at !== index) })}
1014+ />
1015+ ))}
1016+ </ul>
1017+ {editable && (
1018+ <DropdownMenu>
1019+ <DropdownMenuTrigger asChild>
1020+ <Button type="button" variant="quiet">
1021+ <Plus size={14} /> Add a rule
1022+ </Button>
1023+ </DropdownMenuTrigger>
1024+ <DropdownMenuContent align="start" className="max-h-[60vh] w-80 overflow-y-auto">
1025+ {RULE_GROUPS.map((group, groupIndex) => {
1026+ const rules = addable.filter((info) => info.group === group.id);
1027+ if (rules.length === 0) return null;
1028+ return (
1029+ <div key={group.id}>
1030+ {groupIndex > 0 && <DropdownMenuSeparator />}
1031+ <DropdownMenuLabel>{group.label}</DropdownMenuLabel>
1032+ {rules.map((info) => (
1033+ <DropdownMenuItem key={info.type} onSelect={() => update({ rules: [...spec.rules, newRule(info.type as RuleType)] })}>
1034+ <span className="min-w-0">
1035+ <span className="block text-sm">{info.label}</span>
1036+ <span className="block text-xs text-muted">{info.about}</span>
1037+ </span>
1038+ </DropdownMenuItem>
1039+ ))}
1040+ </div>
1041+ );
1042+ })}
1043+ </DropdownMenuContent>
1044+ </DropdownMenu>
1045+ )}
1046+ </Block>
1047+
1048+ <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-3 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
1049+ {editable && (
1050+ <SubmitButton pending="Saving…" match={{ intent: "save" }}>
1051+ {existing ? "Save changes" : "Create ruleset"}
1052+ </SubmitButton>
1053+ )}
1054+ <Link to={backHref} className="text-sm text-muted hover:text-fg">
1055+ {editable ? "Cancel" : "Back to rules"}
1056+ </Link>
1057+ <span className="grow" />
1058+ <Button type="button" variant="quiet" onClick={() => download(spec)}>
1059+ <Download size={14} /> Export JSON
1060+ </Button>
1061+ {editable && (
1062+ <>
1063+ <input
1064+ ref={file}
1065+ type="file"
1066+ accept="application/json,.json"
1067+ className="hidden"
1068+ onChange={async (event) => {
1069+ const chosen = event.target.files?.[0];
1070+ event.target.value = "";
1071+ if (!chosen) return;
1072+ try {
1073+ setSpec(importRuleset(await chosen.text(), level));
1074+ setImported(`Imported ${chosen.name}. Check it, then save.`);
1075+ } catch (problem) {
1076+ setImported(problem instanceof Error ? problem.message : "That file could not be read.");
1077+ }
1078+ }}
1079+ />
1080+ <Button type="button" variant="quiet" onClick={() => file.current?.click()}>
1081+ <FileUp size={14} /> Import JSON
1082+ </Button>
1083+ </>
1084+ )}
1085+ {imported && <p className="w-full text-sm text-muted">{imported}</p>}
1086+ <div className="w-full">
1087+ <ErrorText>{error}</ErrorText>
1088+ </div>
1089+ </div>
1090+ </Form>
1091+ {editable && existing && (
1092+ <Form method="post" className="mt-8 rounded-xl border border-danger/30 p-4">
1093+ <input type="hidden" name="intent" value="delete" />
1094+ <h2 className="font-medium">Delete this ruleset</h2>
1095+ <p className="mt-1 text-sm text-muted">Its rules stop holding at once. Its evaluations stay in Insights.</p>
1096+ <div className="mt-3">
1097+ <SubmitButton variant="danger" pending="Deleting…" match={{ intent: "delete" }}>
1098+ <Trash2 size={14} /> Delete ruleset
1099+ </SubmitButton>
1100+ </div>
1101+ </Form>
1102+ )}
1103+ </>
1104+ );
1105+}
1106+
1107+// --- The list -----------------------------------------------------------------
1108+
1109+function RulesetRow({ ruleset, href, inherited }: { ruleset: Ruleset; href: string; inherited?: boolean }) {
1110+ const agents = ruleset.rules.some((rule) => rule.applies_to === "agents" || ruleInfo(rule.type)?.group === "agents");
1111+ return (
1112+ <li>
1113+ <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">
1114+ <span className="mt-0.5 shrink-0 text-muted">
1115+ {ruleset.enforcement === "disabled" ? <ShieldOff size={16} /> : <ShieldCheck size={16} className={ruleset.enforcement === "active" ? "text-accent" : "text-info"} />}
1116+ </span>
1117+ <span className="min-w-0 grow basis-48">
1118+ <span className="flex flex-wrap items-center gap-2">
1119+ <span className="font-medium group-hover:underline">{ruleset.name}</span>
1120+ <EnforcementBadge enforcement={ruleset.enforcement} />
1121+ {inherited && <Badge>From the workspace</Badge>}
1122+ {ruleset.source === "branch_protection" && <Badge>Branch protection</Badge>}
1123+ {agents && (
1124+ <Badge tone="merged">
1125+ <Bot size={11} /> Agent rules
1126+ </Badge>
1127+ )}
1128+ </span>
1129+ <span className="mt-1 block text-sm text-muted">
1130+ {ruleset.target === "tag" ? "Tags" : "Branches"}: {targetSummary(ruleset)} · {ruleset.rules.length} {ruleset.rules.length === 1 ? "rule" : "rules"}
1131+ {ruleset.bypass_actors.length > 0 && ` · ${ruleset.bypass_actors.length} may bypass`}
1132+ </span>
1133+ </span>
1134+ <span className="shrink-0 text-xs text-faint">
1135+ Changed <TimeAgo at={ruleset.updated_at} /> by <span className="font-mono">{ruleset.updated_by}</span>
1136+ </span>
1137+ </Link>
1138+ </li>
1139+ );
1140+}
1141+
1142+export function RulesetList({
1143+ rulesets,
1144+ hrefFor,
1145+ level,
1146+ empty,
1147+}: {
1148+ rulesets: Ruleset[];
1149+ hrefFor: (ruleset: Ruleset) => string;
1150+ level: Level;
1151+ empty: ReactNode;
1152+}) {
1153+ if (rulesets.length === 0) {
1154+ return <div className="rounded-xl border border-dashed border-line p-8 text-center text-sm text-muted">{empty}</div>;
1155+ }
1156+ return (
1157+ <ul className="space-y-2">
1158+ {rulesets.map((ruleset) => (
1159+ <RulesetRow key={ruleset.id} ruleset={ruleset} href={hrefFor(ruleset)} inherited={level === "repository" && ruleset.level === "workspace"} />
1160+ ))}
1161+ </ul>
1162+ );
1163+}
1164+
1165+// --- Effective rules ----------------------------------------------------------
1166+
1167+/** Every rule that holds for one branch, grouped by where it comes from. */
1168+export function EffectiveRulesView({ effective, hrefFor }: { effective: EffectiveRules; hrefFor: (id: string, level: Level) => string }) {
1169+ if (effective.rules.length === 0) {
1170+ return (
1171+ <p className="rounded-xl border border-dashed border-line p-6 text-sm text-muted">
1172+ No ruleset targets <span className="font-mono text-fg">{effective.name}</span>: anyone who may push can change it however they like.
1173+ </p>
1174+ );
1175+ }
1176+ return (
1177+ <div className="space-y-3">
1178+ {effective.rulesets.map((ruleset) => {
1179+ const rules = effective.rules.filter((rule) => rule.ruleset_id === ruleset.id);
1180+ return (
1181+ <div key={ruleset.id} className="rounded-xl border border-line">
1182+ <div className="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2.5">
1183+ <Link to={hrefFor(ruleset.id, ruleset.level)} className="font-medium hover:underline">
1184+ {ruleset.name}
1185+ </Link>
1186+ <EnforcementBadge enforcement={ruleset.enforcement} />
1187+ {ruleset.level === "workspace" && <Badge>Workspace</Badge>}
1188+ <span className="grow" />
1189+ <span className="flex items-center gap-1 text-xs text-muted">
1190+ <Users size={12} />
1191+ {ruleset.bypass_actors.length === 0 ? "Nobody bypasses" : ruleset.bypass_actors.map(describeBypassActor).join(", ")}
1192+ </span>
1193+ </div>
1194+ <ul className="divide-y divide-line">
1195+ {rules.map((rule, index) => (
1196+ <li key={index} className="flex flex-wrap items-center gap-2 px-4 py-2 text-sm">
1197+ <span className="grow">{ruleInfo(rule.type)?.label ?? rule.type}</span>
1198+ {rule.applies_to !== "everyone" && <Badge tone="merged">{describeAppliesTo(rule.applies_to)}</Badge>}
1199+ </li>
1200+ ))}
1201+ </ul>
1202+ </div>
1203+ );
1204+ })}
1205+ </div>
1206+ );
1207+}
1208+
1209+// --- Insights -----------------------------------------------------------------
1210+
1211+function Stat({ label, value, tone }: { label: string; value: number; tone?: "danger" | "info" | "warn" }) {
1212+ return (
1213+ <div className="rounded-xl border border-line p-3">
1214+ <p className="text-xs text-muted">{label}</p>
1215+ <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>
1216+ </div>
1217+ );
1218+}
1219+
1220+const ACTION_LABEL: Record<string, string> = {
1221+ push: "Push",
1222+ merge: "Merge",
1223+ create_ref: "Create",
1224+ delete_ref: "Delete",
1225+ rename_ref: "Rename",
1226+ commit: "Commit",
1227+};
1228+
1229+function verdictBadge(evaluation: Evaluation) {
1230+ if (evaluation.verdict === "pass") return <Badge tone="accent">Passed</Badge>;
1231+ if (evaluation.verdict === "bypass") return <Badge tone="warn">Bypassed</Badge>;
1232+ if (evaluation.enforcement === "evaluate") return <Badge tone="info">Would block</Badge>;
1233+ return <Badge tone="danger">Blocked</Badge>;
1234+}
1235+
1236+/** How the rules judged pushes and merges: the last 30 days, and each evaluation. */
1237+export function InsightsView({ page, showRepository, olderHref }: { page: EvaluationPage; showRepository?: boolean; olderHref?: string | null }) {
1238+ const insights = page.insights;
1239+ return (
1240+ <div className="space-y-6">
1241+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
1242+ <Stat label={`Evaluations, ${insights.days} days`} value={insights.total} />
1243+ <Stat label="Passed" value={insights.passed} />
1244+ <Stat label="Blocked" value={insights.blocked} tone="danger" />
1245+ <Stat label="Would block" value={insights.would_block} tone="info" />
1246+ <Stat label="Bypassed" value={insights.bypassed} tone="warn" />
1247+ </div>
1248+ {insights.by_rule.length > 0 && (
1249+ <div>
1250+ <h3 className="mb-2 text-sm font-medium">Rules broken most</h3>
1251+ <ul className="space-y-1.5">
1252+ {insights.by_rule.slice(0, 6).map((row) => {
1253+ const top = insights.by_rule[0]!.count || 1;
1254+ return (
1255+ <li key={row.rule} className="flex items-center gap-3 text-sm">
1256+ <span className="w-56 shrink-0 truncate">{ruleInfo(row.rule)?.label ?? row.rule}</span>
1257+ <span className="h-2 grow overflow-hidden rounded-full bg-surface">
1258+ <span className="block h-full rounded-full bg-danger/70" style={{ width: `${Math.max(4, (row.count / top) * 100)}%` }} />
1259+ </span>
1260+ <span className="w-10 shrink-0 text-right tabular-nums text-muted">{row.count}</span>
1261+ </li>
1262+ );
1263+ })}
1264+ </ul>
1265+ </div>
1266+ )}
1267+ <div>
1268+ <h3 className="mb-2 text-sm font-medium">Recent evaluations</h3>
1269+ {page.evaluations.length === 0 ? (
1270+ <p className="rounded-xl border border-dashed border-line p-6 text-sm text-muted">Nothing evaluated yet. Pushes and merges show here as the rules judge them.</p>
1271+ ) : (
1272+ <ul className="divide-y divide-line rounded-xl border border-line">
1273+ {page.evaluations.map((evaluation) => (
1274+ <li key={evaluation.id} className="px-4 py-3">
1275+ <div className="flex flex-wrap items-center gap-2 text-sm">
1276+ {verdictBadge(evaluation)}
1277+ <span className="font-medium">{ACTION_LABEL[evaluation.action] ?? evaluation.action}</span>
1278+ <span className="font-mono text-[0.8125rem] text-muted">{evaluation.git_ref.replace(/^refs\/(heads|tags)\//, "")}</span>
1279+ {evaluation.number != null && <span className="text-muted">#{evaluation.number}</span>}
1280+ <span className="text-muted">
1281+ by <span className="font-mono">{evaluation.actor}</span>
1282+ {evaluation.actor_kind !== "person" && <Bot size={12} className="ml-1 inline text-accent" />}
1283+ </span>
1284+ {showRepository && evaluation.repository && <span className="text-faint">{evaluation.repository}</span>}
1285+ <span className="grow" />
1286+ <span className="text-xs text-faint">
1287+ {evaluation.ruleset_name} · <TimeAgo at={evaluation.created_at} />
1288+ </span>
1289+ </div>
1290+ {evaluation.violations.length > 0 && (
1291+ <ul className="mt-1.5 space-y-0.5">
1292+ {evaluation.violations.map((violation, index) => (
1293+ <li key={index} className="text-sm text-muted">
1294+ {violation.message}
1295+ </li>
1296+ ))}
1297+ </ul>
1298+ )}
1299+ </li>
1300+ ))}
1301+ </ul>
1302+ )}
1303+ {olderHref && (
1304+ <Link to={olderHref} className="mt-3 inline-block text-sm text-muted hover:text-fg">
1305+ Older evaluations →
1306+ </Link>
1307+ )}
1308+ </div>
1309+ </div>
1310+ );
1311+}
1312+
1313+// --- The merge box ------------------------------------------------------------
1314+
1315+/** Rules a pull request does not meet, each with its ruleset and how to meet it. */
1316+export function ViolationList({ violations, tone = "danger" }: { violations: Violation[]; tone?: "danger" | "info" | "warn" }) {
1317+ const grouped = useMemo(() => {
1318+ const seen = new Set<string>();
1319+ return violations.filter((violation) => {
1320+ const key = `${violation.rule}:${violation.message}`;
1321+ if (seen.has(key)) return false;
1322+ seen.add(key);
1323+ return true;
1324+ });
1325+ }, [violations]);
1326+ return (
1327+ <ul className="space-y-2">
1328+ {grouped.map((violation, index) => (
1329+ <li key={index} className="flex items-start gap-2 text-sm">
1330+ <ShieldAlert size={14} className={cn("mt-0.5 shrink-0", tone === "danger" ? "text-danger" : tone === "info" ? "text-info" : "text-warn")} />
1331+ <span className="min-w-0">
1332+ <span className="text-fg">{violation.message}</span>{" "}
1333+ {violation.remedy && <span className="text-muted">{violation.remedy}</span>}
1334+ <span className="mt-0.5 block text-xs text-faint">
1335+ {ruleInfo(violation.rule)?.label ?? violation.rule} · {violation.ruleset_name}
1336+ </span>
1337+ </span>
1338+ </li>
1339+ ))}
1340+ </ul>
1341+ );
1342+}
+11−2
1−import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react";
1+import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react";
22 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
471471 * still open these.
472472 */
473473 const SETTINGS_PAGE =
474− /^\/([^/]+)\/-\/(settings|repositories|tokens|guardrails|secrets|runners|integrations|webhooks|billing|audit)(\/|$)/;
474+ /^\/([^/]+)\/-\/(settings|repositories|tokens|rules|guardrails|secrets|runners|integrations|webhooks|billing|audit)(\/|$)/;
475475 /** A project's settings pages, which the project's menu drills into. */
476476 const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/;
477477
821821 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
822822 Access tokens
823823 </SidebarLink>
824+ <SidebarLink to={`/${slug}/-/rules`} icon={<Scale size={15} />}>
825+ Rules
826+ </SidebarLink>
824827 </div>
825828 <SidebarGroup title="Agents and runs" className="mt-3">
826829 <SidebarLink to={`/${slug}/-/guardrails`} icon={<Gauge size={15} />}>
10421045 Branches and merging
10431046 </SidebarLink>
10441047 )}
1048+ {shows("rules") && (
1049+ <SidebarLink to={`${base}/settings/rules`} icon={<Scale size={15} />}>
1050+ Rules
1051+ </SidebarLink>
1052+ )}
10451053 {shows("secrets") && (
10461054 <SidebarLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}>
10471055 Secrets and variables
13341342 webhooks: "Webhooks",
13351343 domains: "Domains",
13361344 guardrails: "Guardrails",
1345+ rules: "Rules",
13371346 audit: "Audit log",
13381347 tree: "Files",
13391348 blob: "Files",
+1−0
2828 repository: "manage_settings",
2929 agents: "manage_settings",
3030 branches: "manage_protection",
31+ rules: "manage_protection",
3132 guardrails: "manage_protection",
3233 webhooks: "manage_integrations",
3334 secrets: "manage_integrations",
+85−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ ALL,
6+ DEFAULT_BRANCH,
7+ RULES,
8+ describeAppliesTo,
9+ describeBypassActor,
10+ exportRuleset,
11+ importRuleset,
12+ newRule,
13+ newRuleset,
14+ ruleInfo,
15+ targetSummary,
16+} from "./rules.ts";
17+
18+test("a new ruleset protects the default branch, and a workspace's holds everywhere", () => {
19+ const repo = newRuleset("repository");
20+ assert.deepEqual(repo.conditions.ref_name.include, [DEFAULT_BRANCH]);
21+ assert.equal(repo.conditions.repository, undefined);
22+ assert.equal(repo.enforcement, "active");
23+ const workspace = newRuleset("workspace");
24+ assert.deepEqual(workspace.conditions.repository?.include, [ALL]);
25+});
26+
27+test("every rule type has a label, a group and its defaults", () => {
28+ const types = new Set(RULES.map((rule) => rule.type));
29+ assert.equal(types.size, RULES.length, "each type once");
30+ assert.equal(RULES.length, 26);
31+ for (const rule of RULES) {
32+ assert.ok(rule.label && rule.about && rule.targets.length > 0, rule.type);
33+ }
34+ const pull = newRule("pull_request", "agents");
35+ assert.equal(pull.applies_to, "agents");
36+ assert.equal(pull.type === "pull_request" && pull.parameters.required_approvals, 1);
37+ // A new rule's parameters are its own, not the defaults themselves.
38+ const a = newRule("file_path_restriction");
39+ if (a.type === "file_path_restriction") a.parameters.restricted_file_paths.push("x");
40+ const info = ruleInfo("file_path_restriction")!;
41+ assert.deepEqual((info.defaults as { restricted_file_paths: string[] }).restricted_file_paths, []);
42+ assert.equal(ruleInfo("no_such_rule"), undefined);
43+});
44+
45+test("an export imports back as it was", () => {
46+ const ruleset = {
47+ ...newRuleset("repository"),
48+ name: "Protect main",
49+ rules: [newRule("deletion"), newRule("cost_cap", "agents")],
50+ };
51+ const text = JSON.stringify({ ...exportRuleset(ruleset), id: "rs_1", created_by: "ada" });
52+ const back = importRuleset(text, "repository");
53+ assert.deepEqual(back, exportRuleset(ruleset));
54+});
55+
56+test("an import fills in what it leaves out and refuses what is not a ruleset", () => {
57+ const spec = importRuleset(
58+ JSON.stringify({ ruleset_name: "From the API", enforcement: "nonsense", rules: [{ type: "max_file_size", parameters: { max_file_size_mb: 5 } }] }),
59+ "workspace",
60+ );
61+ assert.equal(spec.name, "From the API");
62+ assert.equal(spec.enforcement, "active");
63+ assert.equal(spec.rules[0]?.applies_to, "everyone");
64+ assert.deepEqual(spec.conditions.repository?.include, [ALL]);
65+ assert.equal(spec.rules[0]?.type === "max_file_size" && spec.rules[0].parameters.max_file_size_mb, 5);
66+ assert.throws(() => importRuleset("{", "repository"), /not JSON/);
67+ assert.throws(() => importRuleset("[]", "repository"), /one JSON object/);
68+ assert.throws(() => importRuleset(JSON.stringify({ rules: [{ type: "teleport" }] }), "repository"), /teleport is not a rule type/);
69+});
70+
71+test("who may bypass, and whose changes a rule holds for, read plainly", () => {
72+ assert.equal(describeBypassActor({ kind: "role", value: "maintain", mode: "always" }), "Maintain role and up");
73+ assert.equal(describeBypassActor({ kind: "role", value: "owner", mode: "always" }), "Workspace owners");
74+ assert.equal(describeBypassActor({ kind: "team", value: "acme/release", mode: "pull_requests" }), "@acme/release");
75+ assert.equal(describeBypassActor({ kind: "g1t", value: "", mode: "always" }), "g1t");
76+ assert.equal(describeBypassActor({ kind: "token", value: "workspace", mode: "always" }), "The workspace's tokens");
77+ assert.equal(describeAppliesTo("agents"), "Agents' changes");
78+ assert.equal(describeAppliesTo("everyone"), "Everyone");
79+});
80+
81+test("what a ruleset targets, in a few words", () => {
82+ assert.equal(targetSummary({ conditions: { ref_name: { include: [DEFAULT_BRANCH, "release/*"], exclude: [] } } }), "Default branch, release/*");
83+ assert.equal(targetSummary({ conditions: { ref_name: { include: [ALL], exclude: ["dependabot/**"] } } }), "All, except dependabot/**");
84+ assert.equal(targetSummary({ conditions: { ref_name: { include: [], exclude: [] } } }), "Nothing yet");
85+});
+221−0
1+/**
2+ * Rulesets on the site: how each rule is shown and set up, a new ruleset
3+ * and rule with their defaults, export and import as JSON, and how people
4+ * read who may bypass a ruleset. The types are `@g1t/contracts`' rules.ts.
5+ */
6+import type {
7+ AppliesTo,
8+ BypassActor,
9+ Conditions,
10+ Enforcement,
11+ Level,
12+ PatternOperator,
13+ PatternParameters,
14+ Rule,
15+ RuleEntry,
16+ RuleType,
17+ RulesetSpec,
18+ Target,
19+} from "@g1t/contracts";
20+
21+/** The default branch, whatever it is called (contracts' DEFAULT_BRANCH). */
22+export const DEFAULT_BRANCH = "~DEFAULT_BRANCH";
23+/** Every branch, tag or repository (contracts' ALL). */
24+export const ALL = "~ALL";
25+
26+/** How each rule is shown and set up on the site. */
27+export type RuleTypeInfo = {
28+ type: RuleType;
29+ label: string;
30+ /** What it does, in a sentence. */
31+ about: string;
32+ group: "branches" | "pull_requests" | "commits" | "files" | "agents";
33+ /** Whether it can target tags, branches or both. */
34+ targets: Target[];
35+ /** Parameters a new rule starts with. */
36+ defaults: Rule["parameters"];
37+ /** Rules that may appear more than once. */
38+ repeatable?: boolean;
39+};
40+
41+const none = {} as Record<string, never>;
42+const pattern = (operator: PatternOperator = "regex"): PatternParameters => ({ name: "", operator, pattern: "", negate: false });
43+
44+export const RULES: RuleTypeInfo[] = [
45+ { type: "creation", label: "Restrict creations", about: "Only bypass actors may create matching branches or tags.", group: "branches", targets: ["branch", "tag"], defaults: none },
46+ { type: "update", label: "Restrict updates", about: "Only bypass actors may push to matching branches or tags, merges included.", group: "branches", targets: ["branch", "tag"], defaults: none },
47+ { type: "deletion", label: "Restrict deletions", about: "Only bypass actors may delete matching branches or tags.", group: "branches", targets: ["branch", "tag"], defaults: none },
48+ { type: "non_fast_forward", label: "Block force pushes", about: "Nobody rewrites history: a push must only add to it.", group: "branches", targets: ["branch", "tag"], defaults: none },
49+ { type: "required_linear_history", label: "Require linear history", about: "No merge commits: rebase instead of merging the branch in.", group: "commits", targets: ["branch", "tag"], defaults: none },
50+ { type: "required_signatures", label: "Require signed commits", about: "Every commit carries an SSH signature g1t verifies against its committer's account.", group: "commits", targets: ["branch", "tag"], defaults: none },
51+ {
52+ type: "pull_request",
53+ label: "Require a pull request before merging",
54+ about: "Changes arrive only through pull requests, with the reviews set here.",
55+ group: "pull_requests",
56+ targets: ["branch"],
57+ defaults: {
58+ required_approvals: 1,
59+ count_agent_approvals: true,
60+ dismiss_stale_reviews_on_push: false,
61+ require_code_owner_review: false,
62+ require_last_push_approval: false,
63+ allowed_merge_methods: [],
64+ },
65+ },
66+ {
67+ type: "required_status_checks",
68+ label: "Require status checks to pass",
69+ about: "Checks that must pass on a pull request's head before it merges, for every file or only some paths.",
70+ group: "pull_requests",
71+ targets: ["branch"],
72+ defaults: { checks: [], strict: false, paths: [], allow_bypass_on_merge: false },
73+ repeatable: true,
74+ },
75+ {
76+ type: "merge_queue",
77+ label: "Require the merge queue",
78+ about: "Merging into the default branch joins the queue, which tests pull requests together before it moves.",
79+ group: "pull_requests",
80+ targets: ["branch"],
81+ defaults: { merge_method: "merge", max_entries_to_build: 4, min_entries_to_merge: 1, min_entries_wait_minutes: 0, check_response_timeout_minutes: 45 },
82+ },
83+ { type: "required_deployments", label: "Require deployments to succeed", about: "A pull request's head must have deployed to these environments.", group: "pull_requests", targets: ["branch"], defaults: { environments: ["preview"] } },
84+ { type: "commit_message_pattern", label: "Commit message pattern", about: "Every commit message must match, or must not.", group: "commits", targets: ["branch", "tag"], defaults: pattern(), repeatable: true },
85+ { type: "commit_author_email_pattern", label: "Commit author email pattern", about: "Every author address must match, or must not.", group: "commits", targets: ["branch", "tag"], defaults: pattern("ends_with"), repeatable: true },
86+ { type: "committer_email_pattern", label: "Committer email pattern", about: "Every committer address must match, or must not.", group: "commits", targets: ["branch", "tag"], defaults: pattern("ends_with"), repeatable: true },
87+ { type: "branch_name_pattern", label: "Branch name pattern", about: "New branches must be named to match, or not to.", group: "branches", targets: ["branch"], defaults: pattern(), repeatable: true },
88+ { type: "tag_name_pattern", label: "Tag name pattern", about: "New tags must be named to match, or not to.", group: "branches", targets: ["tag"], defaults: pattern(), repeatable: true },
89+ { type: "file_path_restriction", label: "Restrict file paths", about: "Changes to matching paths are refused.", group: "files", targets: ["branch", "tag"], defaults: { restricted_file_paths: [] }, repeatable: true },
90+ { type: "file_extension_restriction", label: "Restrict file extensions", about: "Files with these extensions may not be added.", group: "files", targets: ["branch", "tag"], defaults: { restricted_file_extensions: [] } },
91+ { type: "max_file_size", label: "Restrict file size", about: "No file larger than this.", group: "files", targets: ["branch", "tag"], defaults: { max_file_size_mb: 10 } },
92+ { type: "max_file_path_length", label: "Restrict file path length", about: "No path longer than this.", group: "files", targets: ["branch", "tag"], defaults: { max_file_path_length: 255 } },
93+ { type: "max_files_changed", label: "Restrict files changed", about: "A commit may change at most this many files.", group: "files", targets: ["branch", "tag"], defaults: { max_files: 100 } },
94+ { type: "secret_scanning", label: "Block pushes that add secrets", about: "Every push is scanned; one too large to scan is refused instead of let through.", group: "files", targets: ["branch", "tag"], defaults: none },
95+ { type: "confidence_threshold", label: "Confidence threshold", about: "An agent's change rated below this needs approvals from people.", group: "agents", targets: ["branch"], defaults: { minimum: "medium", required_approvals: 1 } },
96+ { type: "cost_cap", label: "Cost cap", about: "Over this much agent spend, a pull request waits for a person before it merges or its agent continues.", group: "agents", targets: ["branch"], defaults: { max_usd: 10 } },
97+ { type: "path_review", label: "Review for sensitive paths", about: "Changes to these paths need approvals from people, from a team if you name one.", group: "agents", targets: ["branch"], defaults: { paths: [], required_approvals: 1, team: null }, repeatable: true },
98+ { type: "merge_window", label: "Merge window", about: "When pull requests may merge: weekly hours, freezes and exceptions.", group: "agents", targets: ["branch"], defaults: { time_zone: "UTC", windows: [], freezes: [], exceptions: [] } },
99+ { type: "agent_auto_merge", label: "Agent auto-merge", about: "Whether an agent's ready change lands here without a person, and how sure g1t must be.", group: "agents", targets: ["branch"], defaults: { allowed: true, minimum_confidence: null } },
100+];
101+
102+export const RULE_GROUPS: { id: RuleTypeInfo["group"]; label: string }[] = [
103+ { id: "branches", label: "Branches and tags" },
104+ { id: "pull_requests", label: "Pull requests and checks" },
105+ { id: "commits", label: "Commits" },
106+ { id: "files", label: "Files" },
107+ { id: "agents", label: "Agents, review and timing" },
108+];
109+
110+/** A rule type's information, or undefined for an unknown type. */
111+export function ruleInfo(type: string): RuleTypeInfo | undefined {
112+ return RULES.find((rule) => rule.type === type);
113+}
114+
115+/** A new ruleset, as the form starts it. */
116+export function newRuleset(level: Level): RulesetSpec {
117+ return {
118+ name: "",
119+ enforcement: "active",
120+ target: "branch",
121+ conditions: {
122+ ref_name: { include: [DEFAULT_BRANCH], exclude: [] },
123+ ...(level === "workspace" ? { repository: { include: [ALL], exclude: [], visibility: "any" as const, topics: [] } } : {}),
124+ },
125+ bypass_actors: [],
126+ rules: [],
127+ };
128+}
129+
130+/** A new rule of a type, with its defaults. */
131+export function newRule(type: RuleType, appliesTo: AppliesTo = "everyone"): RuleEntry {
132+ const info = ruleInfo(type);
133+ return { type, parameters: structuredClone(info?.defaults ?? {}), applies_to: appliesTo } as RuleEntry;
134+}
135+
136+/** What a ruleset exports as: its spec, nothing about where it was kept. */
137+export function exportRuleset(ruleset: RulesetSpec): RulesetSpec {
138+ const { name, enforcement, target, conditions, bypass_actors, rules } = ruleset;
139+ return { name, enforcement, target, conditions, bypass_actors, rules };
140+}
141+
142+const ENFORCEMENTS: Enforcement[] = ["active", "evaluate", "disabled"];
143+const APPLIES: AppliesTo[] = ["everyone", "agents", "people"];
144+
145+/**
146+ * A ruleset from imported JSON: an exported one, or one as the API shows it
147+ * (`ruleset_name` read as its name). Parameters left out take their
148+ * defaults. Throws with what is wrong.
149+ */
150+export function importRuleset(text: string, level: Level): RulesetSpec {
151+ let raw: unknown;
152+ try {
153+ raw = JSON.parse(text);
154+ } catch {
155+ throw new Error("That is not JSON.");
156+ }
157+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("A ruleset is one JSON object.");
158+ const found = raw as Record<string, unknown>;
159+ const base = newRuleset(level);
160+ const name = typeof found.name === "string" ? found.name : typeof found.ruleset_name === "string" ? found.ruleset_name : "";
161+ const rules = Array.isArray(found.rules) ? (found.rules as Record<string, unknown>[]) : [];
162+ for (const rule of rules) {
163+ if (typeof rule?.type !== "string" || !ruleInfo(rule.type)) throw new Error(`${String(rule?.type)} is not a rule type.`);
164+ }
165+ const conditions = (found.conditions as Partial<Conditions> | undefined) ?? base.conditions;
166+ return {
167+ name,
168+ enforcement: ENFORCEMENTS.includes(found.enforcement as Enforcement) ? (found.enforcement as Enforcement) : "active",
169+ target: found.target === "tag" ? "tag" : "branch",
170+ conditions: {
171+ ref_name: { include: conditions.ref_name?.include ?? [], exclude: conditions.ref_name?.exclude ?? [] },
172+ ...(level === "workspace" ? { repository: conditions.repository ?? base.conditions.repository } : {}),
173+ },
174+ bypass_actors: Array.isArray(found.bypass_actors) ? (found.bypass_actors as BypassActor[]) : [],
175+ rules: rules.map((rule) => {
176+ const info = ruleInfo(rule.type as string)!;
177+ return {
178+ type: info.type,
179+ parameters: { ...structuredClone(info.defaults), ...((rule.parameters as object) ?? {}) },
180+ applies_to: APPLIES.includes(rule.applies_to as AppliesTo) ? (rule.applies_to as AppliesTo) : "everyone",
181+ } as RuleEntry;
182+ }),
183+ };
184+}
185+
186+/** How people read who a bypass actor is. */
187+export function describeBypassActor(actor: BypassActor): string {
188+ switch (actor.kind) {
189+ case "g1t":
190+ return "g1t";
191+ case "role":
192+ return actor.value === "owner" ? "Workspace owners" : `${actor.value[0]?.toUpperCase() ?? ""}${actor.value.slice(1)} role and up`;
193+ case "team":
194+ case "user":
195+ return `@${actor.value}`;
196+ case "token":
197+ return actor.value === "workspace" ? "The workspace's tokens" : `Token ${actor.value}`;
198+ }
199+}
200+
201+/** How people read whose changes a rule holds for. */
202+export function describeAppliesTo(appliesTo: AppliesTo): string {
203+ return appliesTo === "agents" ? "Agents' changes" : appliesTo === "people" ? "People's changes" : "Everyone";
204+}
205+
206+function patternLabel(pattern: string): string {
207+ if (pattern === DEFAULT_BRANCH) return "Default branch";
208+ if (pattern === ALL) return "All";
209+ return pattern;
210+}
211+
212+/** A name pattern as people read it: `~DEFAULT_BRANCH` is "Default branch". */
213+export { patternLabel };
214+
215+/** What a ruleset targets, in a few words: "Default branch, release/*". */
216+export function targetSummary(ruleset: Pick<RulesetSpec, "conditions">): string {
217+ const include = ruleset.conditions.ref_name.include.map(patternLabel);
218+ const exclude = ruleset.conditions.ref_name.exclude.map(patternLabel);
219+ const what = include.length === 0 ? "Nothing yet" : include.join(", ");
220+ return exclude.length ? `${what}, except ${exclude.join(", ")}` : what;
221+}
+4−0
103103 route("-/audit", "routes/workspace/audit.tsx"),
104104 route("-/audit/export", "routes/workspace/audit-export.ts"),
105105 route("-/guardrails", "routes/workspace/guardrails.tsx"),
106+ route("-/rules", "routes/workspace/rules.tsx"),
107+ route("-/rules/:id", "routes/workspace/ruleset.tsx"),
106108 // What the workspace will have across its projects.
107109 route("-/soon/:feature", "routes/workspace/soon.tsx"),
108110 ]),
180182 route("settings/repository", "routes/repo/settings-repository.tsx"),
181183 route("settings/access", "routes/repo/settings-access.tsx"),
182184 route("settings/branches", "routes/repo/settings-branches.tsx"),
185+ route("settings/rules", "routes/repo/settings-rules.tsx"),
186+ route("settings/rules/:id", "routes/repo/settings-ruleset.tsx"),
183187 route("settings/webhooks", "routes/repo/webhooks.tsx"),
184188 route("settings/secrets", "routes/repo/secrets.tsx"),
185189 route("settings/runners", "routes/repo/settings-runners.tsx"),
+79−9
99 CircleSlash,
1010 GitMerge,
1111 Layers,
12+ Scale,
1213 GitPullRequestArrow,
1314 Hand,
1415 Loader,
2425 Users,
2526 Wrench,
2627 } from "lucide-react";
27−import { Suspense } from "react";
28+import { Suspense, useState } from "react";
2829 import { Await, Form, Link, redirect } from "react-router";
2930
3031 import {
7879 verdicts,
7980 } from "../../components/work";
8081 import { CatchUpProgress, ChecksSection, ConflictsSection, MergeabilityRow, runIdOf } from "../../components/merge-box";
82+import { ViolationList } from "../../components/rules";
8183 import { PullCodeOwnersPanel, TeamReviewer } from "../../components/codeowners";
8284 import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up";
8385 import { notFound } from "../../lib/not-found.server";
241243 members: members?.ok ? members.value.map((person) => person.username) : [],
242244 // `workspace/slug` and name of each team the viewer can see.
243245 teams: teamList?.ok ? teamList.value.map((team) => ({ ref: `${team.workspace}/${team.slug}`, name: team.name })) : [],
244− requireUpToDate: protectedBase && settings.ok && settings.value.requireUpToDate,
245− mergeQueue: protectedBase && settings.ok && settings.value.mergeQueue,
246− requiredApprovals: protectedBase && settings.ok ? settings.value.requiredApprovals : 0,
247− canIgnoreChecks: !settings.ok || settings.value.allowIgnoringChecks,
246+ // What the rules of the branch it merges into ask, as they stack; the
247+ // default branch's settings where the rules are not known.
248+ requireUpToDate: found.value.rules ? found.value.rules.strict : protectedBase && settings.ok && settings.value.requireUpToDate,
249+ mergeQueue: found.value.rules ? found.value.rules.merge_queue : protectedBase && settings.ok && settings.value.mergeQueue,
250+ requiredApprovals: found.value.rules
251+ ? found.value.rules.required_approvals
252+ : protectedBase && settings.ok
253+ ? settings.value.requiredApprovals
254+ : 0,
255+ canIgnoreChecks: found.value.rules ? found.value.rules.allow_bypass_on_merge : !settings.ok || settings.value.allowIgnoringChecks,
248256 noChecks,
249257 // Who may open the pull request that adds CI: anyone who can push.
250258 canAddCi: can.push,
392400 ? await work.mergePull(user, path, number, {
393401 keepIssueOpen: form.get("keepIssueOpen") === "on",
394402 ignoreChecks: form.get("ignoreChecks") === "on",
403+ bypassRules: form.get("bypassRules") === "on",
395404 })
396405 : action === "unqueue"
397406 ? await work.removeFromQueue(user, path, number)
596605 members,
597606 teams,
598607 codeOwners,
608+ rules,
599609 tab,
600610 session,
601611 comparison,
680690 const probing = active && mergeable === "checking";
681691 const conflicting = active && mergeable === "conflicting";
682692 useRefreshWhile(working || checking || reviewPending || catchingUp || settling || moving || landing || probing);
693+ // The rules of its base it does not meet: checks show in their own
694+ // section, so the rest stop the button here. Someone a ruleset lets
695+ // bypass it may tick a box and merge past them.
696+ const [bypassing, setBypassing] = useState(false);
697+ const rulesUnmet = (rules?.unmet ?? []).filter((violation) => violation.rule !== "required_status_checks");
698+ const rulesBypassable = rules?.bypassable ?? [];
683699 // Why the merge button cannot be pressed, if it cannot.
684700 const mergeBlocked = conflicting
685701 ? "Resolve the conflicts first."
686702 : probing
687703 ? "Waiting to find out whether it merges cleanly."
688704 : behind && requireUpToDate
689− ? `This repository requires it to be up to date with ${defaultBranch} first.`
705+ ? `The rules for ${defaultBranch} require it to be up to date first.`
690706 : unchecked && !canIgnoreChecks
691707 ? `The checks ${defaultBranch} requires have to pass first.`
692− : ownersMissing
693− ? "Code owners have to approve first."
694− : null;
708+ : rulesUnmet.length > 0
709+ ? rulesUnmet[0]!.message
710+ : rulesBypassable.length > 0 && !bypassing
711+ ? `Rules for ${defaultBranch} are not met. You may bypass them.`
712+ : ownersMissing
713+ ? "Code owners have to approve first."
714+ : null;
695715
696716 return (
697717 // The changes get the whole width; people and settings are a tab away.
11801200 </StatusRow>
11811201 )
11821202 )}
1203+ {active && rules && (rulesUnmet.length > 0 || rulesBypassable.length > 0 || rules.evaluate.length > 0) && (
1204+ <StatusRow
1205+ icon={
1206+ <Scale
1207+ size={16}
1208+ className={rulesUnmet.length > 0 ? "text-danger" : rulesBypassable.length > 0 ? "text-warn" : "text-info"}
1209+ />
1210+ }
1211+ title={
1212+ rulesUnmet.length > 0
1213+ ? `Rules for ${defaultBranch} are not met yet`
1214+ : rulesBypassable.length > 0
1215+ ? `You may bypass the rules for ${defaultBranch}`
1216+ : "Rulesets being evaluated would refuse this merge"
1217+ }
1218+ >
1219+ <div className="mt-2 space-y-3">
1220+ {rulesUnmet.length > 0 && <ViolationList violations={rulesUnmet} />}
1221+ {rulesBypassable.length > 0 && (
1222+ <div>
1223+ <p className="mb-1.5 text-xs text-muted">Not met, but a ruleset lets you bypass it:</p>
1224+ <ViolationList violations={rulesBypassable} tone="warn" />
1225+ </div>
1226+ )}
1227+ {rules.evaluate.length > 0 && (
1228+ <div>
1229+ <p className="mb-1.5 text-xs text-muted">Rulesets in evaluate would refuse it for this; nothing is held up:</p>
1230+ <ViolationList violations={rules.evaluate} tone="info" />
1231+ </div>
1232+ )}
1233+ {canProtect && (
1234+ <Link
1235+ to={`${base}/settings/rules?branch=${encodeURIComponent(defaultBranch)}`}
1236+ className="inline-block text-xs text-muted underline underline-offset-2 hover:text-fg"
1237+ >
1238+ See every rule for {defaultBranch}
1239+ </Link>
1240+ )}
1241+ </div>
1242+ </StatusRow>
1243+ )}
11831244 {stalled && !lifecycle && (
11841245 <StatusRow icon={<Hand size={16} className="text-warn" />} title="Needs you">
11851246 {stalled}
12121273 labelClassName="text-xs text-muted"
12131274 />
12141275 )}
1276+ {rulesBypassable.length > 0 && rulesUnmet.length === 0 && (
1277+ <CheckboxOption
1278+ name="bypassRules"
1279+ checked={bypassing}
1280+ onCheckedChange={(checked) => setBypassing(checked === true)}
1281+ label={`Bypass the rules for ${defaultBranch}: merge although they are not met. It is recorded.`}
1282+ labelClassName="text-xs text-muted"
1283+ />
1284+ )}
12151285 <div className="flex flex-wrap items-center gap-3">
12161286 <SubmitButton
12171287 variant="accent"
+127−160
1−import { ChevronRight, ShieldCheck } from "lucide-react";
1+import { ChevronRight, Scale, ShieldCheck } from "lucide-react";
22 import { Suspense } from "react";
33 import { Await, Form, Link } from "react-router";
44
5+import { ruleInfo } from "../../lib/rules";
6+
57 import type { Route } from "./+types/settings-branches";
68 import { page } from "../../lib/meta";
79 import { AddCiPrompt } from "../../components/add-ci";
810 import { CodeownersReportPanel, CodeownersReportSkeleton } from "../../components/codeowners";
911 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10−import { RequiredChecksPicker } from "../../components/required-checks";
12+import { EnforcementBadge } from "../../components/rules";
1113 import { SettingChoice as Choice, SettingsSection as Section, SettingToggle as Toggle } from "../../components/settings-section";
1214 import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
1315 import { actions, repos, work } from "../../lib/services.server";
2931 work.seenChecks(path, viewer),
3032 actions.workflows(path, viewer),
3133 ]);
34+ const found = unwrap(repo);
35+ // What holds for the default branch: shown here, changed in Rules.
36+ const effective = await work.effectiveRules(path, found.defaultBranch, viewer).catch(() => null);
3237 // Streamed: the CODEOWNERS file is read and checked on its own time.
3338 const codeowners = work
3439 .codeownersErrors(path, viewer)
35− .then((found) => (found.ok ? found.value : null))
40+ .then((report) => (report.ok ? report.value : null))
3641 .catch(() => null);
3742 return {
3843 codeowners,
39− repo: unwrap(repo),
44+ repo: found,
4045 settings: unwrap(settings),
41− // The names to choose required checks from: what reported lately.
42− seen: seen.ok ? seen.value : [],
46+ effective: effective?.ok ? effective.value : null,
4347 // Nothing to require until something runs: the page offers to add CI.
4448 noChecks: workflows.ok && workflows.value.length === 0 && seen.ok && seen.value.length === 0,
4549 canPush: access.can.push,
5963 const form = await request.formData();
6064 const path = { namespace: params.owner, name: params.repo };
6165 const on = (name: string) => form.get(name) === "on";
62− // Protection belongs to the repository and how its pull requests are
63− // handled to the work service; the page is one form over both.
64− const repo = await repos.update(user, path, { protected: on("protected") });
65− if (!repo.ok) return { saved: false, error: repo.error.message };
66+ // Branch protection is the repository's rulesets' (Settings → Rules):
67+ // what it holds is sent back as it is, so only how g1t's agents work
68+ // changes here.
69+ const current = await work.getSettings(path, user);
70+ if (!current.ok) return { saved: false, error: current.error.message };
6671 const settings = await work.updateSettings(user, path, {
72+ ...current.value,
6773 autoMerge: on("autoMerge"),
68− requiredChecks: form.getAll("requiredChecks").map(String),
69− requireUpToDate: on("requireUpToDate"),
70− requiredApprovals: count(form.get("requiredApprovals"), 0, 6),
71− countAgentApprovals: on("countAgentApprovals"),
72− allowIgnoringChecks: on("bypassChecks"),
7374 agentReview: on("agentReview"),
7475 maxRevisions: count(form.get("maxRevisions"), 0, 5),
75− mergeQueue: on("mergeQueue"),
7676 holdLowConfidence: on("holdLowConfidence"),
77− requireCodeOwnerReview: on("requireCodeOwnerReview"),
7877 });
7978 return settings.ok ? { saved: true, error: null } : { saved: false, error: settings.error.message };
8079 }
8180
8281 export default function BranchSettings({ loaderData, actionData }: Route.ComponentProps) {
83− const { repo, settings, seen, noChecks, canPush, codeowners } = loaderData;
82+ const { repo, settings, effective, noChecks, canPush, codeowners } = loaderData;
8483 const branch = repo.defaultBranch;
8584 const base = `/${repo.namespace}/${repo.name}`;
8685 const archived = Boolean(repo.archivedAt);
86+ const active = effective?.rules.filter((rule) => rule.enforcement === "active") ?? [];
87+ const labels = [...new Set(active.map((rule) => ruleInfo(rule.type)?.label ?? rule.type))];
8788 return (
8889 <>
8990 <RepoSettingsHeading base={base} />
9394 <AddCiPrompt owner={repo.namespace} repo={repo.name} canAdd={canPush && !archived} />
9495 </div>
9596 )}
96− <Form method="post" className="max-w-4xl">
97− <fieldset disabled={archived} className="min-w-0 space-y-8">
98− <Section title="Branch protection" about={`Rules for ${branch}, the branch every pull request merges into. They hold for people and agents alike.`}>
99− <Toggle name="protected" on={repo.protected} title={`Require a pull request to change ${branch}`}>
100− Pushing to {branch} is refused, for members and agents alike, and git says why. Changes reach it only by
101− merging a pull request. The first push to an empty repository is still allowed.
102− </Toggle>
103− <RequiredChecksPicker
104− required={settings.requiredChecks ?? []}
105− seen={seen}
106− mergeQueue={settings.mergeQueue}
107− disabled={archived}
108− />
109− <p className="-mt-4 text-sm text-muted">
110− Code scanning results and dependency review gate merges the same way: require the <strong>Code scanning</strong> and{" "}
111− <strong>Dependency review</strong> checks here once they have reported on a pull request. When each one fails is set in{" "}
112− <Link to={`/${repo.namespace}/${repo.name}/security/settings`} className="underline underline-offset-2 hover:text-fg">
113− Security settings
114− </Link>
115− .
116− </p>
117− <Toggle name="bypassChecks" on={settings.allowIgnoringChecks} title="Allow bypassing required checks">
118− Someone who may merge can tick a box to merge although a required check failed or has not finished, and
119− the pull request says who did. With this off, nobody can, and auto-merge never does.
120− </Toggle>
121− <Toggle
122− name="requireUpToDate"
123− on={settings.requireUpToDate}
124− title="Require branches to be up to date before merging"
125− >
126− With this off, a pull request can be merged after {branch} has moved: g1t brings it up to date as part of
127− merging, and asks you only if there is a conflict it cannot resolve. With it on, it has to catch up first
128− and its required checks pass again on the result, so what lands is exactly what was checked.
129− </Toggle>
130− <Choice
131− name="requiredApprovals"
132− value={settings.requiredApprovals}
133− title="Required approvals"
134− options={[
135− [0, "None"],
136− [1, "1"],
137− [2, "2"],
138− [3, "3"],
139− ]}
140− >
141− How many reviewers must approve before a pull request can merge. A reviewer who has since asked for
142− changes blocks it, and nobody approves their own.
143− </Choice>
144− <Toggle
145− name="requireCodeOwnerReview"
146− on={settings.requireCodeOwnerReview ?? false}
147− title="Require review from code owners"
148− >
149− A pull request waits until the owners of every file it changes, as the CODEOWNERS file on {branch} names
150− them, have approved.
151− </Toggle>
152− <Toggle name="countAgentApprovals" on={settings.countAgentApprovals} title="g1t's approval counts">
153− With this off, required approvals have to come from people, and an agent's review is advice.
154− </Toggle>
155− <Toggle name="mergeQueue" on={settings.mergeQueue} title="Merge through a queue">
156− Merging adds a pull request to the queue instead of changing {branch} at once. g1t builds it together with
157− every pull request ahead of it, several combinations at a time, and runs the workflows that run on{" "}
158− <code className="text-fg">merge_group</code> on each. {branch} only ever moves to a combination whose
159− required checks passed. One that fails leaves the queue and goes back to its author.
160− </Toggle>
161− </Section>
162−
163− <Section
164− id="codeowners"
165− title="CODEOWNERS"
166− about={`Who owns which files, read from ${branch}. Owners are asked to review changes to their files.`}
97+ <div className="max-w-4xl space-y-8">
98+ <Section
99+ title="Branch protection"
100+ about={`What holds for ${branch} and every other branch is set by rulesets: for people and agents alike, and across the workspace.`}
101+ >
102+ <Link
103+ to={`${base}/settings/rules`}
104+ className="group flex items-start gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
167105 >
168− <Suspense fallback={<CodeownersReportSkeleton />}>
169− <Await resolve={codeowners}>
170− {(report) => <CodeownersReportPanel report={report} base={base} branch={branch} />}
171− </Await>
172− </Suspense>
173− </Section>
106+ <Scale size={16} className="mt-0.5 shrink-0 text-accent" />
107+ <span className="min-w-0 grow">
108+ <span className="block text-sm font-medium">Rules for {branch}</span>
109+ <span className="mt-1 block text-sm text-muted">
110+ {labels.length > 0 ? labels.join(" · ") : `No active rules hold for ${branch}: anyone who may push can change it.`}
111+ </span>
112+ {effective && effective.rulesets.length > 0 && (
113+ <span className="mt-2 flex flex-wrap gap-2">
114+ {effective.rulesets.map((ruleset) => (
115+ <span key={ruleset.id} className="inline-flex items-center gap-1.5 text-xs text-muted">
116+ {ruleset.name} <EnforcementBadge enforcement={ruleset.enforcement} />
117+ </span>
118+ ))}
119+ </span>
120+ )}
121+ </span>
122+ <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
123+ </Link>
124+ </Section>
174125
175− <Section
176− title="g1t"
177− about="What happens to a pull request g1t makes, from the moment it is ready. Its checks are the same workflows, and the rules above hold."
178− >
179− <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent">
180− A different agent reads each change and posts comments on lines, a summary and a verdict. If it asks for
181− changes, the author is sent back to make them. With this off, review is left to people.
182− </Toggle>
183− <Choice
184− name="maxRevisions"
185− value={settings.maxRevisions}
186− title="Revisions before asking you"
187− options={[
188− [0, "None"],
189− [1, "1"],
190− [2, "2"],
191− [3, "3"],
192− [5, "5"],
193− ]}
126+ <Section
127+ id="codeowners"
128+ title="CODEOWNERS"
129+ about={`Who owns which files, read from ${branch}. Owners are asked to review changes to their files.`}
130+ >
131+ <Suspense fallback={<CodeownersReportSkeleton />}>
132+ <Await resolve={codeowners}>
133+ {(report) => <CodeownersReportPanel report={report} base={base} branch={branch} />}
134+ </Await>
135+ </Suspense>
136+ </Section>
137+
138+ <Form method="post">
139+ <fieldset disabled={archived} className="min-w-0 space-y-8">
140+ <Section
141+ title="g1t"
142+ about="What happens to a pull request g1t makes, from the moment it is ready. Its checks are the same workflows, and the branch's rules hold."
194143 >
195− How many times an agent is sent back to fix a failed check, with what its jobs printed, or to address a
196− review, before g1t stops and the pull request says it needs you. After that, only a required check that
197− still fails holds it.
198− </Choice>
199− <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready">
200− A pull request g1t made lands without anyone pressing merge once every rule above is met, its required
201− checks included. With this off, it waits for a member. Pull requests from people and from other agents
202− always wait.
203− </Toggle>
204− <Toggle
205− name="holdLowConfidence"
206− on={settings.holdLowConfidence}
207− title="Ask a person before merging low-confidence changes"
208− >
209− g1t rates how sure it is of each change an agent finishes, from its required checks, revisions, review,
210− tests, size and guardrails. One it rates low waits for a member to approve it, instead of merging by itself
211− or joining the queue, and shows on Mission control as needing you.
212− </Toggle>
213− <Link
214− to={`${base}/settings/guardrails`}
215− className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
216− >
217− <ShieldCheck size={16} className="shrink-0 text-accent" />
218− <span className="min-w-0 grow">
219− <span className="block text-sm font-medium">Guardrails</span>
220− <span className="mt-0.5 block text-sm text-muted">
221− What agents may reach, run and spend while they work on this repository.
144+ <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent">
145+ A different agent reads each change and posts comments on lines, a summary and a verdict. If it asks for
146+ changes, the author is sent back to make them. With this off, review is left to people.
147+ </Toggle>
148+ <Choice
149+ name="maxRevisions"
150+ value={settings.maxRevisions}
151+ title="Revisions before asking you"
152+ options={[
153+ [0, "None"],
154+ [1, "1"],
155+ [2, "2"],
156+ [3, "3"],
157+ [5, "5"],
158+ ]}
159+ >
160+ How many times an agent is sent back to fix a failed check, with what its jobs printed, or to address a
161+ review, before g1t stops and the pull request says it needs you. After that, only a required check that
162+ still fails holds it.
163+ </Choice>
164+ <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready">
165+ A pull request g1t made lands without anyone pressing merge once every rule of its branch is met, its
166+ required checks included. A ruleset can turn this off for some branches, or ask for a confidence first
167+ (Agent auto-merge). With this off, it waits for a member. Pull requests from people and from other agents
168+ always wait.
169+ </Toggle>
170+ <Toggle
171+ name="holdLowConfidence"
172+ on={settings.holdLowConfidence}
173+ title="Ask a person before merging low-confidence changes"
174+ >
175+ g1t rates how sure it is of each change an agent finishes, from its required checks, revisions, review,
176+ tests, size and guardrails. One it rates low waits for a member to approve it, instead of merging by itself
177+ or joining the queue, and shows on Mission control as needing you. A Confidence threshold rule asks for
178+ more, branch by branch.
179+ </Toggle>
180+ <Link
181+ to={`${base}/settings/guardrails`}
182+ className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
183+ >
184+ <ShieldCheck size={16} className="shrink-0 text-accent" />
185+ <span className="min-w-0 grow">
186+ <span className="block text-sm font-medium">Guardrails</span>
187+ <span className="mt-0.5 block text-sm text-muted">
188+ What agents may reach, run and spend while they work on this repository.
189+ </span>
222190 </span>
223− </span>
224− <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
225− </Link>
226− </Section>
191+ <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
192+ </Link>
193+ </Section>
227194
228− <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
229− <SubmitButton pending="Saving…" disabled={archived}>
230− Save settings
231− </SubmitButton>
232− {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
233− <ErrorText>{actionData?.error}</ErrorText>
234− {settings.updatedBy && settings.updatedAt && !actionData && (
235− <span className="text-xs text-faint">
236− Merge rules last changed by <span className="font-mono">{settings.updatedBy}</span>{" "}
237− <TimeAgo at={settings.updatedAt} />
238− </span>
239− )}
240− </div>
241− </fieldset>
242− </Form>
195+ <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
196+ <SubmitButton pending="Saving…" disabled={archived}>
197+ Save settings
198+ </SubmitButton>
199+ {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
200+ <ErrorText>{actionData?.error}</ErrorText>
201+ {settings.updatedBy && settings.updatedAt && !actionData && (
202+ <span className="text-xs text-faint">
203+ Last changed by <span className="font-mono">{settings.updatedBy}</span> <TimeAgo at={settings.updatedAt} />
204+ </span>
205+ )}
206+ </div>
207+ </fieldset>
208+ </Form>
209+ </div>
243210 </>
244211 );
245212 }
+148−0
1+import { ChartColumn, Plus, Search, ShieldCheck } from "lucide-react";
2+import { Form, Link, useSearchParams } from "react-router";
3+
4+import type { Route } from "./+types/settings-rules";
5+import { page } from "../../lib/meta";
6+import { RepoSettingsHeading } from "../../components/repo-settings-heading";
7+import { EffectiveRulesView, InsightsView, RulesetList } from "../../components/rules";
8+import { ButtonLink } from "../../components/ui";
9+import { Input } from "../../components/ui/input";
10+import { work } from "../../lib/services.server";
11+import { getViewer, unwrap } from "../../lib/session.server";
12+import { requireInsider } from "../../lib/access.server";
13+
14+export function meta({ params, ...args }: Route.MetaArgs) {
15+ return page(args, { title: `Rules · ${params.owner}/${params.repo} · g1t` });
16+}
17+
18+export async function loader({ params, context, request }: Route.LoaderArgs) {
19+ const viewer = getViewer(context);
20+ // Maintain and up, as for branch protection.
21+ const { repo, access } = await requireInsider(context, params, "manage_protection");
22+ const url = new URL(request.url);
23+ const tab = url.searchParams.get("tab") === "insights" ? "insights" : "rulesets";
24+ const path = { namespace: params.owner, name: params.repo };
25+ const tagged = url.searchParams.get("tag");
26+ const branch = (tagged ?? url.searchParams.get("branch") ?? repo.defaultBranch).trim() || repo.defaultBranch;
27+ const [rulesets, effective, evaluations] = await Promise.all([
28+ work.listRulesets({ repo: path }, viewer, true),
29+ tab === "rulesets" ? work.effectiveRules(path, branch, viewer, tagged ? "tag" : "branch") : Promise.resolve(null),
30+ tab === "insights"
31+ ? work.ruleEvaluations({ repo: path }, viewer, {
32+ before: url.searchParams.get("before") ?? undefined,
33+ problems_only: url.searchParams.get("problems") === "1",
34+ })
35+ : Promise.resolve(null),
36+ ]);
37+ return {
38+ tab,
39+ branch,
40+ target: tagged ? ("tag" as const) : ("branch" as const),
41+ defaultBranch: repo.defaultBranch,
42+ rulesets: unwrap(rulesets),
43+ effective: effective?.ok ? effective.value : null,
44+ evaluations: evaluations?.ok ? evaluations.value : null,
45+ editable: access.can.manage_protection && !repo.archivedAt,
46+ };
47+}
48+
49+export default function RepoRules({ loaderData, params }: Route.ComponentProps) {
50+ const { tab, branch, target, defaultBranch, rulesets, effective, evaluations, editable } = loaderData;
51+ const base = `/${params.owner}/${params.repo}`;
52+ const [search] = useSearchParams();
53+ const own = rulesets.filter((ruleset) => ruleset.level === "repository");
54+ const inherited = rulesets.filter((ruleset) => ruleset.level === "workspace");
55+ const hrefFor = (id: string, level: "repository" | "workspace") => (level === "workspace" ? `/${params.owner}/-/rules/${id}` : `${base}/settings/rules/${id}`);
56+ const tabLink = (name: "rulesets" | "insights") => {
57+ const next = new URLSearchParams(search);
58+ next.delete("before");
59+ if (name === "rulesets") next.delete("tab");
60+ else next.set("tab", name);
61+ const query = next.toString();
62+ return `${base}/settings/rules${query ? `?${query}` : ""}`;
63+ };
64+ return (
65+ <div className="max-w-4xl">
66+ <RepoSettingsHeading base={base} />
67+ <nav className="mb-6 flex gap-1 border-b border-line" aria-label="Rules">
68+ {(["rulesets", "insights"] as const).map((name) => (
69+ <Link
70+ key={name}
71+ to={tabLink(name)}
72+ aria-current={tab === name ? "page" : undefined}
73+ className={`-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm ${tab === name ? "border-accent text-fg" : "border-transparent text-muted hover:text-fg"}`}
74+ >
75+ {name === "rulesets" ? <ShieldCheck size={14} /> : <ChartColumn size={14} />}
76+ {name === "rulesets" ? "Rulesets" : "Insights"}
77+ </Link>
78+ ))}
79+ </nav>
80+
81+ {tab === "rulesets" ? (
82+ <div className="space-y-10">
83+ <section>
84+ <div className="mb-3 flex flex-wrap items-center justify-between gap-3">
85+ <div>
86+ <h2 className="font-medium">This repository's rulesets</h2>
87+ <p className="mt-1 text-sm text-muted">They stack with the workspace's: every rule of each that targets a branch holds there.</p>
88+ </div>
89+ {editable && (
90+ <ButtonLink to={`${base}/settings/rules/new`}>
91+ <Plus size={15} /> New ruleset
92+ </ButtonLink>
93+ )}
94+ </div>
95+ <RulesetList
96+ rulesets={own}
97+ level="repository"
98+ hrefFor={(ruleset) => hrefFor(ruleset.id, ruleset.level)}
99+ empty={
100+ <>
101+ No rulesets yet. Anyone who may push can push to any branch, and pull requests merge without approvals or checks.
102+ {editable && (
103+ <>
104+ {" "}
105+ <Link to={`${base}/settings/rules/new`} className="text-fg underline underline-offset-2">
106+ Create one
107+ </Link>{" "}
108+ to protect {defaultBranch}.
109+ </>
110+ )}
111+ </>
112+ }
113+ />
114+ </section>
115+ {inherited.length > 0 && (
116+ <section>
117+ <h2 className="font-medium">From the workspace</h2>
118+ <p className="mt-1 mb-3 text-sm text-muted">Set for every repository of {params.owner} they select. Owners change them in the workspace's settings.</p>
119+ <RulesetList rulesets={inherited} level="repository" hrefFor={(ruleset) => hrefFor(ruleset.id, ruleset.level)} empty={null} />
120+ </section>
121+ )}
122+ <section>
123+ <h2 className="font-medium">What holds for a branch</h2>
124+ <p className="mt-1 mb-3 text-sm text-muted">Every rule of every ruleset that targets it, active ones first.</p>
125+ <Form method="get" className="mb-4 flex flex-wrap items-center gap-2">
126+ <div className="relative w-72 max-w-full">
127+ <Search size={14} className="absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
128+ <Input name={target === "tag" ? "tag" : "branch"} defaultValue={branch} aria-label="Branch" placeholder={defaultBranch} className="pl-8 font-mono text-[0.8125rem]" />
129+ </div>
130+ <button type="submit" className="rounded-md border border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg">
131+ Show rules
132+ </button>
133+ </Form>
134+ {effective ? (
135+ <EffectiveRulesView effective={effective} hrefFor={hrefFor} />
136+ ) : (
137+ <p className="text-sm text-muted">The rules for that name could not be read.</p>
138+ )}
139+ </section>
140+ </div>
141+ ) : evaluations ? (
142+ <InsightsView page={evaluations} olderHref={evaluations.next ? `${base}/settings/rules?tab=insights&before=${encodeURIComponent(evaluations.next)}` : null} />
143+ ) : (
144+ <p className="text-sm text-muted">Insights could not be read just now.</p>
145+ )}
146+ </div>
147+ );
148+}
+97−0
1+import { ArrowLeft } from "lucide-react";
2+import { Link, data, redirect } from "react-router";
3+
4+import type { RulesetSpec } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/settings-ruleset";
7+import { page } from "../../lib/meta";
8+import { EnforcementBadge, RulesetForm } from "../../components/rules";
9+import { newRuleset } from "../../lib/rules";
10+import { TimeAgo } from "../../components/ui";
11+import { work } from "../../lib/services.server";
12+import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
13+import { requireCapability, requireInsider } from "../../lib/access.server";
14+
15+export function meta({ params, loaderData, ...args }: Route.MetaArgs) {
16+ const name = loaderData?.existing?.name ?? "New ruleset";
17+ return page(args, { title: `${name} · Rules · ${params.owner}/${params.repo} · g1t` });
18+}
19+
20+export async function loader({ params, context }: Route.LoaderArgs) {
21+ const viewer = getViewer(context);
22+ const { repo, access } = await requireInsider(context, params, "manage_protection");
23+ const path = { namespace: params.owner, name: params.repo };
24+ const seen = await work.seenChecks(path, viewer).catch(() => null);
25+ const base = {
26+ seen: seen?.ok ? seen.value : [],
27+ editable: access.can.manage_protection && !repo.archivedAt,
28+ repository: `${repo.namespace}/${repo.name}`,
29+ };
30+ if (params.id === "new") return { ...base, existing: null };
31+ const found = await work.getRuleset({ repo: path }, params.id, viewer);
32+ if (!found.ok && found.error.code === "not_found") throw data(null, { status: 404 });
33+ const existing = unwrap(found);
34+ // A workspace's ruleset is changed in the workspace's settings.
35+ if (existing.level === "workspace") throw redirect(`/${params.owner}/-/rules/${existing.id}`);
36+ return { ...base, existing };
37+}
38+
39+export async function action({ request, params, context }: Route.ActionArgs) {
40+ assertSameOrigin(request);
41+ const user = requireUser(context, request);
42+ await requireCapability(context, params, "manage_protection");
43+ const form = await request.formData();
44+ const owner = { repo: { namespace: params.owner, name: params.repo } };
45+ const list = `/${params.owner}/${params.repo}/settings/rules`;
46+ if (form.get("intent") === "delete" && params.id !== "new") {
47+ const deleted = await work.deleteRuleset(user, owner, params.id);
48+ return deleted.ok ? redirect(list) : { error: deleted.error.message };
49+ }
50+ let ruleset: RulesetSpec;
51+ try {
52+ ruleset = JSON.parse(String(form.get("ruleset") ?? "")) as RulesetSpec;
53+ } catch {
54+ return { error: "The ruleset could not be read. Reload the page and try again." };
55+ }
56+ const saved = await work.saveRuleset(user, owner, ruleset, params.id === "new" ? undefined : params.id);
57+ return saved.ok ? redirect(list) : { error: saved.error.message };
58+}
59+
60+export default function RepoRuleset({ loaderData, actionData, params }: Route.ComponentProps) {
61+ const { existing, seen, editable, repository } = loaderData;
62+ const list = `/${params.owner}/${params.repo}/settings/rules`;
63+ return (
64+ <div className="max-w-4xl">
65+ <header className="mb-6 border-b border-line pb-5">
66+ <Link to={list} className="mb-3 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
67+ <ArrowLeft size={14} /> Rules
68+ </Link>
69+ <h1 className="flex flex-wrap items-center gap-2 text-lg font-semibold tracking-tight">
70+ {existing ? existing.name : "New ruleset"}
71+ {existing && <EnforcementBadge enforcement={existing.enforcement} />}
72+ </h1>
73+ <p className="mt-1 text-sm text-muted">
74+ {existing ? (
75+ <>
76+ Changed <TimeAgo at={existing.updated_at} /> by <span className="font-mono">{existing.updated_by}</span>. Created by{" "}
77+ <span className="font-mono">{existing.created_by}</span>.
78+ </>
79+ ) : (
80+ "What may happen to this repository's branches or tags, and what a pull request needs before it merges."
81+ )}
82+ </p>
83+ </header>
84+ <RulesetForm
85+ key={existing?.id ?? "new"}
86+ initial={existing ?? newRuleset("repository")}
87+ level="repository"
88+ existing={existing ?? undefined}
89+ seen={seen}
90+ editable={editable}
91+ error={actionData && "error" in actionData ? actionData.error : null}
92+ backHref={list}
93+ repositoryLabel={repository}
94+ />
95+ </div>
96+ );
97+}
+92−0
1+import { ChartColumn, Plus, ShieldCheck } from "lucide-react";
2+import { Link, data, useSearchParams } from "react-router";
3+
4+import type { Route } from "./+types/rules";
5+import { page } from "../../lib/meta";
6+import { InsightsView, RulesetList } from "../../components/rules";
7+import { ButtonLink } from "../../components/ui";
8+import { work } from "../../lib/services.server";
9+import { getViewer, roleIn, unwrap } from "../../lib/session.server";
10+
11+export function meta({ params, ...args }: Route.MetaArgs) {
12+ return page(args, { title: `Rules · ${params.owner} · g1t` });
13+}
14+
15+export async function loader({ params, context, request }: Route.LoaderArgs) {
16+ const viewer = getViewer(context);
17+ const role = roleIn(viewer, params.owner);
18+ if (!role) throw data(null, { status: 404 });
19+ const url = new URL(request.url);
20+ const tab = url.searchParams.get("tab") === "insights" ? "insights" : "rulesets";
21+ const owner = { workspace: params.owner };
22+ const [rulesets, evaluations] = await Promise.all([
23+ work.listRulesets(owner, viewer),
24+ tab === "insights" ? work.ruleEvaluations(owner, viewer, { before: url.searchParams.get("before") ?? undefined }) : Promise.resolve(null),
25+ ]);
26+ return {
27+ tab,
28+ rulesets: unwrap(rulesets),
29+ evaluations: evaluations?.ok ? evaluations.value : null,
30+ editable: role === "owner",
31+ };
32+}
33+
34+export default function WorkspaceRules({ loaderData, params }: Route.ComponentProps) {
35+ const { tab, rulesets, evaluations, editable } = loaderData;
36+ const base = `/${params.owner}/-/rules`;
37+ const [search] = useSearchParams();
38+ const tabLink = (name: "rulesets" | "insights") => {
39+ const next = new URLSearchParams(search);
40+ next.delete("before");
41+ if (name === "rulesets") next.delete("tab");
42+ else next.set("tab", name);
43+ const query = next.toString();
44+ return `${base}${query ? `?${query}` : ""}`;
45+ };
46+ return (
47+ <div className="mx-auto max-w-4xl">
48+ <header className="mb-6 border-b border-line pb-5">
49+ <h1 className="text-lg font-semibold tracking-tight">Rules</h1>
50+ <p className="mt-1 text-sm text-muted">
51+ Rulesets that hold across {params.owner}'s repositories: which branches and tags they cover, who may bypass them, and what a pull
52+ request needs before it merges. Each repository's own rulesets stack with these.
53+ </p>
54+ </header>
55+ <nav className="mb-6 flex gap-1 border-b border-line" aria-label="Rules">
56+ {(["rulesets", "insights"] as const).map((name) => (
57+ <Link
58+ key={name}
59+ to={tabLink(name)}
60+ aria-current={tab === name ? "page" : undefined}
61+ className={`-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm ${tab === name ? "border-accent text-fg" : "border-transparent text-muted hover:text-fg"}`}
62+ >
63+ {name === "rulesets" ? <ShieldCheck size={14} /> : <ChartColumn size={14} />}
64+ {name === "rulesets" ? "Rulesets" : "Insights"}
65+ </Link>
66+ ))}
67+ </nav>
68+ {tab === "rulesets" ? (
69+ <section>
70+ <div className="mb-3 flex flex-wrap items-center justify-between gap-3">
71+ <p className="text-sm text-muted">{editable ? "Owners create and change them." : "Owners create and change them; you can read them."}</p>
72+ {editable && (
73+ <ButtonLink to={`${base}/new`}>
74+ <Plus size={15} /> New ruleset
75+ </ButtonLink>
76+ )}
77+ </div>
78+ <RulesetList
79+ rulesets={rulesets}
80+ level="workspace"
81+ hrefFor={(ruleset) => `${base}/${ruleset.id}`}
82+ empty="No workspace rulesets yet. Each repository's own rulesets still hold."
83+ />
84+ </section>
85+ ) : evaluations ? (
86+ <InsightsView page={evaluations} showRepository olderHref={evaluations.next ? `${base}?tab=insights&before=${encodeURIComponent(evaluations.next)}` : null} />
87+ ) : (
88+ <p className="text-sm text-muted">Insights could not be read just now.</p>
89+ )}
90+ </div>
91+ );
92+}
+87−0
1+import { ArrowLeft } from "lucide-react";
2+import { Link, data, redirect } from "react-router";
3+
4+import type { RulesetSpec } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/ruleset";
7+import { page } from "../../lib/meta";
8+import { EnforcementBadge, RulesetForm } from "../../components/rules";
9+import { newRuleset } from "../../lib/rules";
10+import { TimeAgo } from "../../components/ui";
11+import { work } from "../../lib/services.server";
12+import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
13+
14+export function meta({ params, loaderData, ...args }: Route.MetaArgs) {
15+ const name = loaderData?.existing?.name ?? "New ruleset";
16+ return page(args, { title: `${name} · Rules · ${params.owner} · g1t` });
17+}
18+
19+export async function loader({ params, context }: Route.LoaderArgs) {
20+ const viewer = getViewer(context);
21+ const role = roleIn(viewer, params.owner);
22+ if (!role) throw data(null, { status: 404 });
23+ const editable = role === "owner";
24+ if (params.id === "new") {
25+ if (!editable) throw data("Only owners of the workspace can create its rulesets.", { status: 403 });
26+ return { existing: null, editable };
27+ }
28+ const found = await work.getRuleset({ workspace: params.owner }, params.id, viewer);
29+ if (!found.ok && found.error.code === "not_found") throw data(null, { status: 404 });
30+ return { existing: unwrap(found), editable };
31+}
32+
33+export async function action({ request, params, context }: Route.ActionArgs) {
34+ assertSameOrigin(request);
35+ const user = requireUser(context, request);
36+ const form = await request.formData();
37+ const owner = { workspace: params.owner };
38+ const list = `/${params.owner}/-/rules`;
39+ if (form.get("intent") === "delete" && params.id !== "new") {
40+ const deleted = await work.deleteRuleset(user, owner, params.id);
41+ return deleted.ok ? redirect(list) : { error: deleted.error.message };
42+ }
43+ let ruleset: RulesetSpec;
44+ try {
45+ ruleset = JSON.parse(String(form.get("ruleset") ?? "")) as RulesetSpec;
46+ } catch {
47+ return { error: "The ruleset could not be read. Reload the page and try again." };
48+ }
49+ const saved = await work.saveRuleset(user, owner, ruleset, params.id === "new" ? undefined : params.id);
50+ return saved.ok ? redirect(list) : { error: saved.error.message };
51+}
52+
53+export default function WorkspaceRuleset({ loaderData, actionData, params }: Route.ComponentProps) {
54+ const { existing, editable } = loaderData;
55+ const list = `/${params.owner}/-/rules`;
56+ return (
57+ <div className="mx-auto max-w-4xl">
58+ <header className="mb-6 border-b border-line pb-5">
59+ <Link to={list} className="mb-3 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
60+ <ArrowLeft size={14} /> Rules
61+ </Link>
62+ <h1 className="flex flex-wrap items-center gap-2 text-lg font-semibold tracking-tight">
63+ {existing ? existing.name : "New ruleset"}
64+ {existing && <EnforcementBadge enforcement={existing.enforcement} />}
65+ </h1>
66+ <p className="mt-1 text-sm text-muted">
67+ {existing ? (
68+ <>
69+ Changed <TimeAgo at={existing.updated_at} /> by <span className="font-mono">{existing.updated_by}</span>.
70+ </>
71+ ) : (
72+ `Rules for the branches or tags of ${params.owner}'s repositories, all or some.`
73+ )}
74+ </p>
75+ </header>
76+ <RulesetForm
77+ key={existing?.id ?? "new"}
78+ initial={existing ?? newRuleset("workspace")}
79+ level="workspace"
80+ existing={existing ?? undefined}
81+ editable={editable}
82+ error={actionData && "error" in actionData ? actionData.error : null}
83+ backHref={list}
84+ />
85+ </div>
86+ );
87+}
+9−0
12221222 pub rulesets: Vec<RulesetSummary>,
12231223 /// Whether merging joins the merge queue.
12241224 pub merge_queue: bool,
1225+ /// What the active rules ask, as they stack: the approvals a merge
1226+ /// needs, whether it must be up to date, and whether a merger may merge
1227+ /// past required checks that have not passed.
1228+ #[serde(default)]
1229+ pub required_approvals: u32,
1230+ #[serde(default)]
1231+ pub strict: bool,
1232+ #[serde(default)]
1233+ pub allow_bypass_on_merge: bool,
12251234 }
12261235
12271236 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+8−0
395395 catchUpJob: (pullId) => call("catch_up_job", { pullId }),
396396 wakeForMessages: (pullId) => call("wake_for_messages", { pullId }),
397397 getSettings: (repo, viewer) => call("get_settings", { repo, viewer }),
398+ // Rulesets travel in snake_case (rules.ts).
399+ listRulesets: (owner, viewer, includeParents = false) =>
400+ call("list_rulesets", { viewer, ...owner, include_parents: includeParents }),
401+ getRuleset: (owner, id, viewer) => call("get_ruleset", { viewer, ...owner, id }),
402+ saveRuleset: (actor, owner, ruleset, id) => call("save_ruleset", { actor, ...owner, id: id ?? null, ruleset }),
403+ deleteRuleset: (actor, owner, id) => call("delete_ruleset", { actor, ...owner, id }),
404+ effectiveRules: (repo, name, viewer, target = "branch") => call("effective_rules", { viewer, repo, name, target }),
405+ ruleEvaluations: (owner, viewer, filter = {}) => call("rule_evaluations", { viewer, ...owner, ...filter }),
398406 seenChecks: (repo, viewer) => call("seen_checks", { repo, viewer }),
399407 codeownersErrors: (repo, viewer, ref) => call("codeowners_errors", { repo, viewer, ref: ref ?? null }),
400408 updateSettings: (actor, repo, settings) =>
+1−0
2626 export * from "./projects";
2727 export * from "./repos";
2828 export * from "./result";
29+export * from "./rules";
2930 export * from "./runner";
3031 export * from "./runners";
3132 export * from "./scopes";
+241−0
1+import type { RepoPath } from "./repos";
2+import type { Result } from "./result";
3+import type { User, Viewer } from "./identity";
4+import type { ConfidenceLevel } from "./work";
5+
6+/**
7+ * Rulesets: what may happen to a repository's branches and tags, and what a
8+ * pull request needs before it merges. A ruleset belongs to a repository or
9+ * to a workspace (and through it to the repositories it selects), targets
10+ * branches or tags by name, is `active`, `evaluate` (a dry run that records
11+ * what it would have refused) or `disabled`, lists who may bypass it, and
12+ * holds rules. Rulesets stack: every rule of each holds.
13+ *
14+ * Mirrors `crates/contracts/src/rules.rs`. Rulesets travel in the shape the
15+ * API shows them, `snake_case`, so an export imports anywhere unchanged.
16+ */
17+
18+export const DEFAULT_BRANCH = "~DEFAULT_BRANCH";
19+export const ALL = "~ALL";
20+export const MAX_RULESETS = 75;
21+
22+export type Enforcement = "active" | "evaluate" | "disabled";
23+export type Target = "branch" | "tag";
24+export type Level = "repository" | "workspace";
25+export type AppliesTo = "everyone" | "agents" | "people";
26+export type BypassActorKind = "role" | "team" | "user" | "token" | "g1t";
27+export type BypassMode = "always" | "pull_requests";
28+export type MergeMethod = "merge" | "squash" | "rebase";
29+export type Integration = "actions" | "deployments" | "security" | "g1t";
30+export type PatternOperator = "starts_with" | "ends_with" | "contains" | "regex";
31+
32+export type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
33+
34+export type RefCondition = { include: string[]; exclude: string[] };
35+export type RepositoryCondition = {
36+ include: string[];
37+ exclude: string[];
38+ visibility: "any" | "public" | "private";
39+ topics: string[];
40+};
41+export type Conditions = { ref_name: RefCondition; repository?: RepositoryCondition };
42+export type BypassActor = { kind: BypassActorKind; value: string; mode: BypassMode };
43+
44+export type PullRequestParameters = {
45+ required_approvals: number;
46+ count_agent_approvals: boolean;
47+ dismiss_stale_reviews_on_push: boolean;
48+ require_code_owner_review: boolean;
49+ require_last_push_approval: boolean;
50+ allowed_merge_methods: MergeMethod[];
51+ /** Only the ruleset made from branch protection that did not refuse pushes. */
52+ allow_direct_pushes?: boolean;
53+};
54+export type RulesetCheck = { context: string; integration?: Integration };
55+export type StatusChecksParameters = {
56+ checks: RulesetCheck[];
57+ strict: boolean;
58+ paths: string[];
59+ allow_bypass_on_merge: boolean;
60+};
61+export type MergeQueueParameters = {
62+ merge_method: MergeMethod;
63+ max_entries_to_build: number;
64+ min_entries_to_merge: number;
65+ min_entries_wait_minutes: number;
66+ check_response_timeout_minutes: number;
67+};
68+export type PatternParameters = { name: string; operator: PatternOperator; pattern: string; negate: boolean };
69+export type WeeklyWindow = { days: Weekday[]; start: string; end: string };
70+export type Period = { start: string; end?: string | null; reason: string };
71+export type MergeWindowParameters = {
72+ time_zone: string;
73+ windows: WeeklyWindow[];
74+ freezes: Period[];
75+ exceptions: Period[];
76+};
77+
78+/** Every rule type, with its parameters. */
79+export type Rule =
80+ | { type: "creation"; parameters: Record<string, never> }
81+ | { type: "update"; parameters: Record<string, never> }
82+ | { type: "deletion"; parameters: Record<string, never> }
83+ | { type: "non_fast_forward"; parameters: Record<string, never> }
84+ | { type: "required_linear_history"; parameters: Record<string, never> }
85+ | { type: "required_signatures"; parameters: Record<string, never> }
86+ | { type: "pull_request"; parameters: PullRequestParameters }
87+ | { type: "required_status_checks"; parameters: StatusChecksParameters }
88+ | { type: "merge_queue"; parameters: MergeQueueParameters }
89+ | { type: "required_deployments"; parameters: { environments: string[] } }
90+ | { type: "commit_message_pattern"; parameters: PatternParameters }
91+ | { type: "commit_author_email_pattern"; parameters: PatternParameters }
92+ | { type: "committer_email_pattern"; parameters: PatternParameters }
93+ | { type: "branch_name_pattern"; parameters: PatternParameters }
94+ | { type: "tag_name_pattern"; parameters: PatternParameters }
95+ | { type: "file_path_restriction"; parameters: { restricted_file_paths: string[] } }
96+ | { type: "file_extension_restriction"; parameters: { restricted_file_extensions: string[] } }
97+ | { type: "max_file_size"; parameters: { max_file_size_mb: number } }
98+ | { type: "max_file_path_length"; parameters: { max_file_path_length: number } }
99+ | { type: "max_files_changed"; parameters: { max_files: number } }
100+ | { type: "secret_scanning"; parameters: Record<string, never> }
101+ | { type: "confidence_threshold"; parameters: { minimum: ConfidenceLevel; required_approvals: number } }
102+ | { type: "cost_cap"; parameters: { max_usd: number } }
103+ | { type: "path_review"; parameters: { paths: string[]; required_approvals: number; team?: string | null } }
104+ | { type: "merge_window"; parameters: MergeWindowParameters }
105+ | { type: "agent_auto_merge"; parameters: { allowed: boolean; minimum_confidence?: ConfidenceLevel | null } };
106+
107+export type RuleType = Rule["type"];
108+export type RuleEntry = Rule & { applies_to: AppliesTo };
109+
110+/** What a ruleset says: what is created, changed, exported and imported. */
111+export type RulesetSpec = {
112+ name: string;
113+ enforcement: Enforcement;
114+ target: Target;
115+ conditions: Conditions;
116+ bypass_actors: BypassActor[];
117+ rules: RuleEntry[];
118+};
119+
120+export type Ruleset = RulesetSpec & {
121+ id: string;
122+ level: Level;
123+ workspace: string;
124+ repo_id?: string;
125+ repository?: string;
126+ /** `branch_protection` for the one made from branch protection settings. */
127+ source?: string;
128+ created_by: string;
129+ created_at: string;
130+ updated_by: string;
131+ updated_at: string;
132+};
133+
134+export type RulesetSummary = {
135+ id: string;
136+ name: string;
137+ level: Level;
138+ enforcement: Enforcement;
139+ bypass_actors: BypassActor[];
140+};
141+
142+export type EffectiveRule = RuleEntry & {
143+ ruleset_id: string;
144+ ruleset_name: string;
145+ level: Level;
146+ enforcement: Enforcement;
147+};
148+
149+export type EffectiveRules = {
150+ name: string;
151+ target: Target;
152+ default_branch: boolean;
153+ rules: EffectiveRule[];
154+ rulesets: RulesetSummary[];
155+};
156+
157+export type RuleVerdict = "pass" | "fail" | "bypass";
158+export type Action = "push" | "merge" | "create_ref" | "delete_ref" | "rename_ref" | "commit";
159+
160+export type Violation = {
161+ rule: string;
162+ ruleset_id: string;
163+ ruleset_name: string;
164+ enforcement: Enforcement;
165+ message: string;
166+ remedy: string;
167+};
168+
169+export type Evaluation = {
170+ id: string;
171+ repo_id: string;
172+ workspace: string;
173+ ruleset_id: string;
174+ ruleset_name: string;
175+ enforcement: Enforcement;
176+ action: Action;
177+ git_ref: string;
178+ actor: string;
179+ actor_kind: string;
180+ verdict: RuleVerdict;
181+ violations: Violation[];
182+ number: number | null;
183+ sha: string | null;
184+ repository: string;
185+ created_at: string;
186+};
187+
188+export type Insights = {
189+ days: number;
190+ total: number;
191+ passed: number;
192+ blocked: number;
193+ would_block: number;
194+ bypassed: number;
195+ by_ruleset: {
196+ ruleset_id: string;
197+ ruleset_name: string;
198+ enforcement: Enforcement;
199+ total: number;
200+ blocked: number;
201+ would_block: number;
202+ bypassed: number;
203+ }[];
204+ by_rule: { rule: string; count: number }[];
205+};
206+
207+export type EvaluationPage = { evaluations: Evaluation[]; next: string | null; insights: Insights };
208+
209+/** What a pull request's merge box shows of the rules of its base. */
210+export type MergeRules = {
211+ unmet: Violation[];
212+ bypassable: Violation[];
213+ evaluate: Violation[];
214+ rulesets: RulesetSummary[];
215+ merge_queue: boolean;
216+ /** What the active rules ask, as they stack. */
217+ required_approvals: number;
218+ strict: boolean;
219+ allow_bypass_on_merge: boolean;
220+};
221+
222+/** Whose rulesets: a repository's or a workspace's. */
223+export type RulesetOwner = { repo: RepoPath } | { workspace: string };
224+
225+export type EvaluationFilter = {
226+ ruleset_id?: string;
227+ verdict?: RuleVerdict;
228+ problems_only?: boolean;
229+ before?: string;
230+ limit?: number;
231+};
232+
233+export interface RulesApi {
234+ listRulesets(owner: RulesetOwner, viewer: Viewer, includeParents?: boolean): Promise<Result<Ruleset[]>>;
235+ getRuleset(owner: RulesetOwner, id: string, viewer: Viewer): Promise<Result<Ruleset>>;
236+ /** Creates one (no `id`) or replaces one. */
237+ saveRuleset(actor: User, owner: RulesetOwner, ruleset: RulesetSpec, id?: string): Promise<Result<Ruleset>>;
238+ deleteRuleset(actor: User, owner: RulesetOwner, id: string): Promise<Result<boolean>>;
239+ effectiveRules(repo: RepoPath, name: string, viewer: Viewer, target?: Target): Promise<Result<EffectiveRules>>;
240+ ruleEvaluations(owner: RulesetOwner, viewer: Viewer, filter?: EvaluationFilter): Promise<Result<EvaluationPage>>;
241+}
+14−2
22 import type { User, Viewer } from "./identity";
33 import type { PullBranchUpdate, RepoPath } from "./repos";
44 import type { Result } from "./result";
5+import type { MergeRules, RulesApi } from "./rules";
56
67 /**
78 * The filter on lists of issues and pull requests. An open pull request is
215216 * not seeing through.
216217 */
217218 confidence?: Confidence | null;
219+ /** Who last moved its head (a user id), and when; absent until a push after rulesets arrived. */
220+ headPushedBy?: string;
221+ headPushedAt?: string;
218222 };
219223
220224 /** How sure g1t is that an agent's change is right. */
464468 * merges into) and whose approval is still needed. Absent without one.
465469 */
466470 codeOwners?: PullCodeOwners | null;
471+ /**
472+ * The rules of the branch it merges into it does not meet yet, for whoever
473+ * is looking: what refuses the merge, what they may bypass, and what rulesets
474+ * in evaluate would refuse. Absent once it is closed or merged.
475+ */
476+ rules?: MergeRules | null;
467477 };
468478
469479 /** Where a required check stands on a commit; `expected` when nothing has reported it yet. */
818828 };
819829
820830 /** Issues, pull requests, comments and sessions. */
821−export interface WorkApi {
831+export interface WorkApi extends RulesApi {
822832 openIssue(actor: User, repo: RepoPath, input: OpenIssueInput): Promise<Result<Issue>>;
823833 /**
824834 * Opens an issue to put g1t on at once: refused, with nothing
10171027 number: number,
10181028 options?: {
10191029 keepIssueOpen?: boolean;
1020− /** Merge although required checks have not passed, where the repository allows bypassing them. */
1030+ /** Merge although required checks have not passed, where the rule requiring them allows it. */
10211031 ignoreChecks?: boolean;
1032+ /** Merge past rules a ruleset lets the actor bypass. Recorded as a bypass. */
1033+ bypassRules?: boolean;
10221034 },
10231035 ): Promise<Result<Pull>>;
10241036 /**
+1−1
14541454 let code_owners = self.pull_code_owners(&pull, &comments, &settings).await?;
14551455 Ok(Outcome::Ok(PullDetail {
14561456 required_checks: required_checks(&settings.required_checks, &statuses),
1457− rules: gate.map(|gate| rulesets::merge_rules(&gate.judged, &gate.requirements)),
1457+ rules: gate.map(|gate| rulesets::merge_rules(&gate.judged, &gate.requirements, pull.base_branch(&repo.default_branch) == repo.default_branch)),
14581458 code_owners,
14591459 comments,
14601460 checks,
+10−2
209209 }
210210
211211 /// What the merge box shows: each rule not met, and how to meet it.
212−pub(crate) fn merge_rules(judged: &[Judged], requirements: &Requirements) -> MergeRules {
213− let mut out = MergeRules { merge_queue: requirements.merge_queue.is_some(), ..MergeRules::default() };
212+/// `default_branch`: whether it merges into the default branch, where the
213+/// merge queue lands.
214+pub(crate) fn merge_rules(judged: &[Judged], requirements: &Requirements, default_branch: bool) -> MergeRules {
215+ let mut out = MergeRules {
216+ merge_queue: requirements.merge_queue.is_some() && default_branch,
217+ required_approvals: requirements.required_approvals,
218+ strict: requirements.strict,
219+ allow_bypass_on_merge: requirements.allow_bypass_on_merge,
220+ ..MergeRules::default()
221+ };
214222 for one in judged {
215223 match (one.enforcement, one.verdict()) {
216224 (Enforcement::Active, Verdict::Fail) => out.unmet.extend(one.violations.iter().cloned()),