| 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 | /** |
| 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 | */ |
| 25 | const RETIRED_RULES: readonly string[] = ["confidence_threshold", "cost_cap"]; |
| 26 | |
| 27 | /** The default branch, whatever it is called (contracts' DEFAULT_BRANCH). */ |
| 28 | export const DEFAULT_BRANCH = "~DEFAULT_BRANCH"; |
| 29 | /** Every branch, tag or repository (contracts' ALL). */ |
| 30 | export const ALL = "~ALL"; |
| 31 | |
| 32 | /** How each rule is shown and set up on the site. */ |
| 33 | export 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 | |
| 47 | const none = {} as Record<string, never>; |
| 48 | const pattern = (operator: PatternOperator = "regex"): PatternParameters => ({ name: "", operator, pattern: "", negate: false }); |
| 49 | |
| 50 | export 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 | |
| 106 | export 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. */ |
| 115 | export 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. */ |
| 120 | export 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. */ |
| 135 | export 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. */ |
| 141 | export 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 | |
| 146 | const ENFORCEMENTS: Enforcement[] = ["active", "evaluate", "disabled"]; |
| 147 | const 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 | */ |
| 154 | export 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. */ |
| 194 | export 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. */ |
| 209 | export function describeAppliesTo(appliesTo: AppliesTo): string { |
| 210 | return appliesTo === "agents" ? "Agents' changes" : appliesTo === "people" ? "People's changes" : "Everyone"; |
| 211 | } |
| 212 | |
| 213 | function 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". */ |
| 220 | export { patternLabel }; |
| 221 | |
| 222 | /** What a ruleset targets, in a few words: "Default branch, release/*". */ |
| 223 | export 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 | } |