Skip to content

g1t/apps/web/app/lib/rules.ts

221 lines12,589 bytesCodeBlame

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

Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1/**
2 * Rulesets on the site: 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 */
6import 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). */
22export const DEFAULT_BRANCH = "~DEFAULT_BRANCH";
23/** Every branch, tag or repository (contracts' ALL). */
24export const ALL = "~ALL";
25
26/** How each rule is shown and set up on the site. */
27export 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
41const none = {} as Record<string, never>;
42const pattern = (operator: PatternOperator = "regex"): PatternParameters => ({ name: "", operator, pattern: "", negate: false });
43
44export 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
102export 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. */
111export 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. */
116export 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. */
131export 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. */
137export 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
142const ENFORCEMENTS: Enforcement[] = ["active", "evaluate", "disabled"];
143const 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 */
150export 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. */
187export 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. */
202export function describeAppliesTo(appliesTo: AppliesTo): string {
203 return appliesTo === "agents" ? "Agents' changes" : appliesTo === "people" ? "People's changes" : "Everyone";
204}
205
206function 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". */
213export { patternLabel };
214
215/** What a ruleset targets, in a few words: "Default branch, release/*". */
216export 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}

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