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 merge | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.