| 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 | { |
| 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 | |
| 260 | export 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. */ |
| 269 | export 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. */ |
| 274 | export 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. */ |
| 289 | export 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. */ |
| 295 | export 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 | |
| 300 | const ENFORCEMENTS: Enforcement[] = ["active", "evaluate", "disabled"]; |
| 301 | const 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 | */ |
| 308 | export 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. */ |
| 348 | export 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. */ |
| 363 | export function describeAppliesTo(appliesTo: AppliesTo): string { |
| 364 | return appliesTo === "agents" ? "Agents' changes" : appliesTo === "people" ? "People's changes" : "Everyone"; |
| 365 | } |
| 366 | |
| 367 | function 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". */ |
| 374 | export { patternLabel }; |
| 375 | |
| 376 | /** What a ruleset targets, in a few words: "Default branch, release/*". */ |
| 377 | export 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 | } |