Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

382 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 {
52 type: "creation",
53 label: "Restrict creations",
54 about: "Only bypass actors may create matching branches or tags.",
55 group: "branches",
56 targets: ["branch", "tag"],
57 defaults: none,
58 },
59 {
60 type: "update",
61 label: "Restrict updates",
62 about: "Only bypass actors may push to matching branches or tags, merges included.",
63 group: "branches",
64 targets: ["branch", "tag"],
65 defaults: none,
66 },
67 {
68 type: "deletion",
69 label: "Restrict deletions",
70 about: "Only bypass actors may delete matching branches or tags.",
71 group: "branches",
72 targets: ["branch", "tag"],
73 defaults: none,
74 },
75 {
76 type: "non_fast_forward",
77 label: "Block force pushes",
78 about: "Nobody rewrites history: a push must only add to it.",
79 group: "branches",
80 targets: ["branch", "tag"],
81 defaults: none,
82 },
83 {
84 type: "required_linear_history",
85 label: "Require linear history",
86 about: "No merge commits: rebase instead of merging the branch in.",
87 group: "commits",
88 targets: ["branch", "tag"],
89 defaults: none,
90 },
91 {
92 type: "required_signatures",
93 label: "Require signed commits",
94 about: "Every commit carries an SSH signature g1t verifies against its committer's account.",
95 group: "commits",
96 targets: ["branch", "tag"],
97 defaults: none,
98 },
99 {
100 type: "pull_request",
101 label: "Require a pull request before merging",
102 about: "Changes arrive only through pull requests, with the reviews set here.",
103 group: "pull_requests",
104 targets: ["branch"],
105 defaults: {
106 required_approvals: 1,
107 count_agent_approvals: true,
108 dismiss_stale_reviews_on_push: false,
109 require_code_owner_review: false,
110 require_last_push_approval: false,
111 allowed_merge_methods: [],
112 },
113 },
114 {
115 type: "required_status_checks",
116 label: "Require status checks to pass",
117 about: "Checks that must pass on a pull request's head before it merges, for every file or only some paths.",
118 group: "pull_requests",
119 targets: ["branch"],
120 defaults: { checks: [], strict: false, paths: [], allow_bypass_on_merge: false },
121 repeatable: true,
122 },
123 {
124 type: "merge_queue",
125 label: "Require the merge queue",
126 about: "Merging into the default branch joins the queue, which tests pull requests together before it moves.",
127 group: "pull_requests",
128 targets: ["branch"],
129 defaults: { merge_method: "merge", max_entries_to_build: 4, min_entries_to_merge: 1, min_entries_wait_minutes: 0, check_response_timeout_minutes: 45 },
130 },
131 {
132 type: "required_deployments",
133 label: "Require deployments to succeed",
134 about: "A pull request's head must have deployed to these environments.",
135 group: "pull_requests",
136 targets: ["branch"],
137 defaults: { environments: ["preview"] },
138 },
139 {
140 type: "commit_message_pattern",
141 label: "Commit message pattern",
142 about: "Every commit message must match, or must not.",
143 group: "commits",
144 targets: ["branch", "tag"],
145 defaults: pattern(),
146 repeatable: true,
147 },
148 {
149 type: "commit_author_email_pattern",
150 label: "Commit author email pattern",
151 about: "Every author address must match, or must not.",
152 group: "commits",
153 targets: ["branch", "tag"],
154 defaults: pattern("ends_with"),
155 repeatable: true,
156 },
157 {
158 type: "committer_email_pattern",
159 label: "Committer email pattern",
160 about: "Every committer address must match, or must not.",
161 group: "commits",
162 targets: ["branch", "tag"],
163 defaults: pattern("ends_with"),
164 repeatable: true,
165 },
166 {
167 type: "branch_name_pattern",
168 label: "Branch name pattern",
169 about: "New branches must be named to match, or not to.",
170 group: "branches",
171 targets: ["branch"],
172 defaults: pattern(),
173 repeatable: true,
174 },
175 {
176 type: "tag_name_pattern",
177 label: "Tag name pattern",
178 about: "New tags must be named to match, or not to.",
179 group: "branches",
180 targets: ["tag"],
181 defaults: pattern(),
182 repeatable: true,
183 },
184 {
185 type: "file_path_restriction",
186 label: "Restrict file paths",
187 about: "Changes to matching paths are refused.",
188 group: "files",
189 targets: ["branch", "tag"],
190 defaults: { restricted_file_paths: [] },
191 repeatable: true,
192 },
193 {
194 type: "file_extension_restriction",
195 label: "Restrict file extensions",
196 about: "Files with these extensions may not be added.",
197 group: "files",
198 targets: ["branch", "tag"],
199 defaults: { restricted_file_extensions: [] },
200 },
201 {
202 type: "max_file_size",
203 label: "Restrict file size",
204 about: "No file larger than this.",
205 group: "files",
206 targets: ["branch", "tag"],
207 defaults: { max_file_size_mb: 10 },
208 },
209 {
210 type: "max_file_path_length",
211 label: "Restrict file path length",
212 about: "No path longer than this.",
213 group: "files",
214 targets: ["branch", "tag"],
215 defaults: { max_file_path_length: 255 },
216 },
217 {
218 type: "max_files_changed",
219 label: "Restrict files changed",
220 about: "A commit may change at most this many files.",
221 group: "files",
222 targets: ["branch", "tag"],
223 defaults: { max_files: 100 },
224 },
225 {
226 type: "secret_scanning",
227 label: "Block pushes that add secrets",
228 about: "Every push is scanned; one too large to scan is refused instead of let through.",
229 group: "files",
230 targets: ["branch", "tag"],
231 defaults: none,
232 },
233 {
234 type: "path_review",
235 label: "Review for sensitive paths",
236 about: "Changes to these paths need approvals from people, from a team if you name one.",
237 group: "agents",
238 targets: ["branch"],
239 defaults: { paths: [], required_approvals: 1, team: null },
240 repeatable: true,
241 },
242 {
243 type: "merge_window",
244 label: "Merge window",
245 about: "When pull requests may merge: weekly hours, freezes and exceptions.",
246 group: "agents",
247 targets: ["branch"],
248 defaults: { time_zone: "UTC", windows: [], freezes: [], exceptions: [] },
249 },
250 {
251 type: "agent_auto_merge",
252 label: "Agent auto-merge",
253 about: "Whether an agent's ready change lands here without a person.",
254 group: "agents",
255 targets: ["branch"],
256 defaults: { allowed: true },
257 },
258];
259
260export const RULE_GROUPS: { id: RuleTypeInfo["group"]; label: string }[] = [
261 { id: "branches", label: "Branches and tags" },
262 { id: "pull_requests", label: "Pull requests and checks" },
263 { id: "commits", label: "Commits" },
264 { id: "files", label: "Files" },
265 { id: "agents", label: "Review, timing and auto-merge" },
266];
267
268/** A rule type's information, or undefined for an unknown type. */
269export function ruleInfo(type: string): RuleTypeInfo | undefined {
270 return RULES.find((rule) => rule.type === type);
271}
272
273/** A new ruleset, as the form starts it. */
274export function newRuleset(level: Level): RulesetSpec {
275 return {
276 name: "",
277 enforcement: "active",
278 target: "branch",
279 conditions: {
280 ref_name: { include: [DEFAULT_BRANCH], exclude: [] },
281 ...(level === "workspace" ? { repository: { include: [ALL], exclude: [], visibility: "any" as const, topics: [] } } : {}),
282 },
283 bypass_actors: [],
284 rules: [],
285 };
286}
287
288/** A new rule of a type, with its defaults. */
289export function newRule(type: RuleType, appliesTo: AppliesTo = "everyone"): RuleEntry {
290 const info = ruleInfo(type);
291 return { type, parameters: structuredClone(info?.defaults ?? {}), applies_to: appliesTo } as RuleEntry;
292}
293
294/** What a ruleset exports as: its spec, nothing about where it was kept. */
295export function exportRuleset(ruleset: RulesetSpec): RulesetSpec {
296 const { name, enforcement, target, conditions, bypass_actors, rules } = ruleset;
297 return { name, enforcement, target, conditions, bypass_actors, rules };
298}
299
300const ENFORCEMENTS: Enforcement[] = ["active", "evaluate", "disabled"];
301const APPLIES: AppliesTo[] = ["everyone", "agents", "people"];
302
303/**
304 * A ruleset from imported JSON: an exported one, or one as the API shows it
305 * (`ruleset_name` read as its name). Parameters left out take their
306 * defaults. Throws with what is wrong.
307 */
308export function importRuleset(text: string, level: Level): RulesetSpec {
309 let raw: unknown;
310 try {
311 raw = JSON.parse(text);
312 } catch {
313 throw new Error("That is not JSON.");
314 }
315 if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("A ruleset is one JSON object.");
316 const found = raw as Record<string, unknown>;
317 const base = newRuleset(level);
318 const name = typeof found.name === "string" ? found.name : typeof found.ruleset_name === "string" ? found.ruleset_name : "";
319 // Rule types no longer offered are left out, as the service does.
320 const rules = (Array.isArray(found.rules) ? (found.rules as Record<string, unknown>[]) : []).filter(
321 (rule) => !(typeof rule?.type === "string" && RETIRED_RULES.includes(rule.type)),
322 );
323 for (const rule of rules) {
324 if (typeof rule?.type !== "string" || !ruleInfo(rule.type)) throw new Error(`${String(rule?.type)} is not a rule type.`);
325 }
326 const conditions = (found.conditions as Partial<Conditions> | undefined) ?? base.conditions;
327 return {
328 name,
329 enforcement: ENFORCEMENTS.includes(found.enforcement as Enforcement) ? (found.enforcement as Enforcement) : "active",
330 target: found.target === "tag" ? "tag" : "branch",
331 conditions: {
332 ref_name: { include: conditions.ref_name?.include ?? [], exclude: conditions.ref_name?.exclude ?? [] },
333 ...(level === "workspace" ? { repository: conditions.repository ?? base.conditions.repository } : {}),
334 },
335 bypass_actors: Array.isArray(found.bypass_actors) ? (found.bypass_actors as BypassActor[]) : [],
336 rules: rules.map((rule) => {
337 const info = ruleInfo(rule.type as string)!;
338 return {
339 type: info.type,
340 parameters: { ...structuredClone(info.defaults), ...((rule.parameters as object) ?? {}) },
341 applies_to: APPLIES.includes(rule.applies_to as AppliesTo) ? (rule.applies_to as AppliesTo) : "everyone",
342 } as RuleEntry;
343 }),
344 };
345}
346
347/** How people read who a bypass actor is. */
348export function describeBypassActor(actor: BypassActor): string {
349 switch (actor.kind) {
350 case "g1t":
351 return "g1t";
352 case "role":
353 return actor.value === "owner" ? "Workspace owners" : `${actor.value[0]?.toUpperCase() ?? ""}${actor.value.slice(1)} role and up`;
354 case "team":
355 case "user":
356 return `@${actor.value}`;
357 case "token":
358 return actor.value === "workspace" ? "The workspace's tokens" : `Token ${actor.value}`;
359 }
360}
361
362/** How people read whose changes a rule holds for. */
363export function describeAppliesTo(appliesTo: AppliesTo): string {
364 return appliesTo === "agents" ? "Agents' changes" : appliesTo === "people" ? "People's changes" : "Everyone";
365}
366
367function patternLabel(pattern: string): string {
368 if (pattern === DEFAULT_BRANCH) return "Default branch";
369 if (pattern === ALL) return "All";
370 return pattern;
371}
372
373/** A name pattern as people read it: `~DEFAULT_BRANCH` is "Default branch". */
374export { patternLabel };
375
376/** What a ruleset targets, in a few words: "Default branch, release/*". */
377export function targetSummary(ruleset: Pick<RulesetSpec, "conditions">): string {
378 const include = ruleset.conditions.ref_name.include.map(patternLabel);
379 const exclude = ruleset.conditions.ref_name.exclude.map(patternLabel);
380 const what = include.length === 0 ? "Nothing yet" : include.join(", ");
381 return exclude.length ? `${what}, except ${exclude.join(", ")}` : what;
382}