Skip to content
228 linesCodeBlameRaw
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 */
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/**
22 * Rule types no longer offered (contracts' RETIRED_RULES): an import that
23 * has one leaves it out, as the service does with a stored ruleset.
24 */
25const RETIRED_RULES: readonly string[] = ["confidence_threshold", "cost_cap"];
26
27/** The default branch, whatever it is called (contracts' DEFAULT_BRANCH). */
28export const DEFAULT_BRANCH = "~DEFAULT_BRANCH";
29/** Every branch, tag or repository (contracts' ALL). */
30export const ALL = "~ALL";
31
32/** How each rule is shown and set up on the site. */
33export type RuleTypeInfo = {
34 type: RuleType;
35 label: string;
36 /** What it does, in a sentence. */
37 about: string;
38 group: "branches" | "pull_requests" | "commits" | "files" | "agents";
39 /** Whether it can target tags, branches or both. */
40 targets: Target[];
41 /** Parameters a new rule starts with. */
42 defaults: Rule["parameters"];
43 /** Rules that may appear more than once. */
44 repeatable?: boolean;
45};
46
47const none = {} as Record<string, never>;
48const pattern = (operator: PatternOperator = "regex"): PatternParameters => ({ name: "", operator, pattern: "", negate: false });
49
50export const RULES: RuleTypeInfo[] = [
51 { type: "creation", label: "Restrict creations", about: "Only bypass actors may create matching branches or tags.", group: "branches", targets: ["branch", "tag"], defaults: none },
52 { 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 },
53 { type: "deletion", label: "Restrict deletions", about: "Only bypass actors may delete matching branches or tags.", group: "branches", targets: ["branch", "tag"], defaults: none },
54 { 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 },
55 { 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 },
56 { 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 },
57 {
58 type: "pull_request",
59 label: "Require a pull request before merging",
60 about: "Changes arrive only through pull requests, with the reviews set here.",
61 group: "pull_requests",
62 targets: ["branch"],
63 defaults: {
64 required_approvals: 1,
65 count_agent_approvals: true,
66 dismiss_stale_reviews_on_push: false,
67 require_code_owner_review: false,
68 require_last_push_approval: false,
69 allowed_merge_methods: [],
70 },
71 },
72 {
73 type: "required_status_checks",
74 label: "Require status checks to pass",
75 about: "Checks that must pass on a pull request's head before it merges, for every file or only some paths.",
76 group: "pull_requests",
77 targets: ["branch"],
78 defaults: { checks: [], strict: false, paths: [], allow_bypass_on_merge: false },
79 repeatable: true,
80 },
81 {
82 type: "merge_queue",
83 label: "Require the merge queue",
84 about: "Merging into the default branch joins the queue, which tests pull requests together before it moves.",
85 group: "pull_requests",
86 targets: ["branch"],
87 defaults: { merge_method: "merge", max_entries_to_build: 4, min_entries_to_merge: 1, min_entries_wait_minutes: 0, check_response_timeout_minutes: 45 },
88 },
89 { 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"] } },
90 { 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 },
91 { 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 },
92 { 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 },
93 { 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 },
94 { 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 },
95 { 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 },
96 { 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: [] } },
97 { 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 } },
98 { 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 } },
99 { 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 } },
100 { 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 },
101 { 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 },
102 { 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: [] } },
103 { type: "agent_auto_merge", label: "Agent auto-merge", about: "Whether an agent's ready change lands here without a person.", group: "agents", targets: ["branch"], defaults: { allowed: true } },
104];
105
106export const RULE_GROUPS: { id: RuleTypeInfo["group"]; label: string }[] = [
107 { id: "branches", label: "Branches and tags" },
108 { id: "pull_requests", label: "Pull requests and checks" },
109 { id: "commits", label: "Commits" },
110 { id: "files", label: "Files" },
111 { id: "agents", label: "Review, timing and auto-merge" },
112];
113
114/** A rule type's information, or undefined for an unknown type. */
115export function ruleInfo(type: string): RuleTypeInfo | undefined {
116 return RULES.find((rule) => rule.type === type);
117}
118
119/** A new ruleset, as the form starts it. */
120export function newRuleset(level: Level): RulesetSpec {
121 return {
122 name: "",
123 enforcement: "active",
124 target: "branch",
125 conditions: {
126 ref_name: { include: [DEFAULT_BRANCH], exclude: [] },
127 ...(level === "workspace" ? { repository: { include: [ALL], exclude: [], visibility: "any" as const, topics: [] } } : {}),
128 },
129 bypass_actors: [],
130 rules: [],
131 };
132}
133
134/** A new rule of a type, with its defaults. */
135export function newRule(type: RuleType, appliesTo: AppliesTo = "everyone"): RuleEntry {
136 const info = ruleInfo(type);
137 return { type, parameters: structuredClone(info?.defaults ?? {}), applies_to: appliesTo } as RuleEntry;
138}
139
140/** What a ruleset exports as: its spec, nothing about where it was kept. */
141export function exportRuleset(ruleset: RulesetSpec): RulesetSpec {
142 const { name, enforcement, target, conditions, bypass_actors, rules } = ruleset;
143 return { name, enforcement, target, conditions, bypass_actors, rules };
144}
145
146const ENFORCEMENTS: Enforcement[] = ["active", "evaluate", "disabled"];
147const APPLIES: AppliesTo[] = ["everyone", "agents", "people"];
148
149/**
150 * A ruleset from imported JSON: an exported one, or one as the API shows it
151 * (`ruleset_name` read as its name). Parameters left out take their
152 * defaults. Throws with what is wrong.
153 */
154export function importRuleset(text: string, level: Level): RulesetSpec {
155 let raw: unknown;
156 try {
157 raw = JSON.parse(text);
158 } catch {
159 throw new Error("That is not JSON.");
160 }
161 if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("A ruleset is one JSON object.");
162 const found = raw as Record<string, unknown>;
163 const base = newRuleset(level);
164 const name = typeof found.name === "string" ? found.name : typeof found.ruleset_name === "string" ? found.ruleset_name : "";
165 // Rule types no longer offered are left out, as the service does.
166 const rules = (Array.isArray(found.rules) ? (found.rules as Record<string, unknown>[]) : []).filter(
167 (rule) => !(typeof rule?.type === "string" && RETIRED_RULES.includes(rule.type)),
168 );
169 for (const rule of rules) {
170 if (typeof rule?.type !== "string" || !ruleInfo(rule.type)) throw new Error(`${String(rule?.type)} is not a rule type.`);
171 }
172 const conditions = (found.conditions as Partial<Conditions> | undefined) ?? base.conditions;
173 return {
174 name,
175 enforcement: ENFORCEMENTS.includes(found.enforcement as Enforcement) ? (found.enforcement as Enforcement) : "active",
176 target: found.target === "tag" ? "tag" : "branch",
177 conditions: {
178 ref_name: { include: conditions.ref_name?.include ?? [], exclude: conditions.ref_name?.exclude ?? [] },
179 ...(level === "workspace" ? { repository: conditions.repository ?? base.conditions.repository } : {}),
180 },
181 bypass_actors: Array.isArray(found.bypass_actors) ? (found.bypass_actors as BypassActor[]) : [],
182 rules: rules.map((rule) => {
183 const info = ruleInfo(rule.type as string)!;
184 return {
185 type: info.type,
186 parameters: { ...structuredClone(info.defaults), ...((rule.parameters as object) ?? {}) },
187 applies_to: APPLIES.includes(rule.applies_to as AppliesTo) ? (rule.applies_to as AppliesTo) : "everyone",
188 } as RuleEntry;
189 }),
190 };
191}
192
193/** How people read who a bypass actor is. */
194export function describeBypassActor(actor: BypassActor): string {
195 switch (actor.kind) {
196 case "g1t":
197 return "g1t";
198 case "role":
199 return actor.value === "owner" ? "Workspace owners" : `${actor.value[0]?.toUpperCase() ?? ""}${actor.value.slice(1)} role and up`;
200 case "team":
201 case "user":
202 return `@${actor.value}`;
203 case "token":
204 return actor.value === "workspace" ? "The workspace's tokens" : `Token ${actor.value}`;
205 }
206}
207
208/** How people read whose changes a rule holds for. */
209export function describeAppliesTo(appliesTo: AppliesTo): string {
210 return appliesTo === "agents" ? "Agents' changes" : appliesTo === "people" ? "People's changes" : "Everyone";
211}
212
213function patternLabel(pattern: string): string {
214 if (pattern === DEFAULT_BRANCH) return "Default branch";
215 if (pattern === ALL) return "All";
216 return pattern;
217}
218
219/** A name pattern as people read it: `~DEFAULT_BRANCH` is "Default branch". */
220export { patternLabel };
221
222/** What a ruleset targets, in a few words: "Default branch, release/*". */
223export function targetSummary(ruleset: Pick<RulesetSpec, "conditions">): string {
224 const include = ruleset.conditions.ref_name.include.map(patternLabel);
225 const exclude = ruleset.conditions.ref_name.exclude.map(patternLabel);
226 const what = include.length === 0 ? "Nothing yet" : include.join(", ");
227 return exclude.length ? `${what}, except ${exclude.join(", ")}` : what;
228}