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 | //! Branch protection as it was before rulesets: one set of settings for the |
| 2 | //! default branch. A repository's settings become its "Default branch | |
| 3 | //! protection" ruleset, holding exactly what they held, and the settings | |
| 4 | //! the API still takes for that branch are read from and written to it. | |
| 5 | ||
| 6 | use g1t_contracts::rules::{ | |
| 7 | Conditions, DEFAULT_BRANCH, Enforcement, MergeQueueRule, PullRequestRule, RefCondition, RequiredCheck, Rule, | |
| 8 | RuleEntry, RulesetSpec, StatusChecksRule, Target, | |
| 9 | }; | |
| 10 | use g1t_contracts::work::RepoSettings; | |
| 11 | ||
| 12 | /// The name the ruleset made from branch protection is given. | |
| 13 | pub const NAME: &str = "Default branch protection"; | |
| 14 | ||
| 15 | /// What branch protection held, as rules. `protected`: pushes to the | |
| 16 | /// default branch were refused. | |
| 17 | pub fn rules_of(settings: &RepoSettings, protected: bool) -> Vec<RuleEntry> { | |
| 18 | let mut rules = Vec::new(); | |
| 19 | if protected || settings.required_approvals > 0 || settings.require_code_owner_review { | |
| 20 | rules.push(RuleEntry::everyone(Rule::PullRequest(PullRequestRule { | |
| 21 | required_approvals: settings.required_approvals, | |
| 22 | count_agent_approvals: settings.count_agent_approvals, | |
| 23 | require_code_owner_review: settings.require_code_owner_review, | |
| 24 | allow_direct_pushes: !protected, | |
| 25 | ..PullRequestRule::default() | |
| 26 | }))); | |
| 27 | } | |
| 28 | if !settings.required_checks.is_empty() || settings.require_up_to_date { | |
| 29 | rules.push(RuleEntry::everyone(Rule::RequiredStatusChecks(StatusChecksRule { | |
| 30 | checks: settings | |
| 31 | .required_checks | |
| 32 | .iter() | |
| 33 | .map(|name| RequiredCheck { context: name.clone(), integration: None }) | |
| 34 | .collect(), | |
| 35 | strict: settings.require_up_to_date, | |
| 36 | paths: Vec::new(), | |
| 37 | allow_bypass_on_merge: settings.allow_ignoring_checks, | |
| 38 | }))); | |
| 39 | } | |
| 40 | if settings.merge_queue { | |
| 41 | rules.push(RuleEntry::everyone(Rule::MergeQueue(MergeQueueRule::default()))); | |
| 42 | } | |
| 43 | rules | |
| 44 | } | |
| 45 | ||
| 46 | /// The ruleset branch protection becomes, or `None` when it held nothing. | |
| 47 | pub fn ruleset_of(settings: &RepoSettings, protected: bool) -> Option<RulesetSpec> { | |
| 48 | let rules = rules_of(settings, protected); | |
| 49 | (!rules.is_empty()).then(|| RulesetSpec { | |
| 50 | name: NAME.to_owned(), | |
| 51 | enforcement: Enforcement::Active, | |
| 52 | target: Target::Branch, | |
| 53 | conditions: Conditions { | |
| 54 | ref_name: RefCondition { include: vec![DEFAULT_BRANCH.to_owned()], exclude: Vec::new() }, | |
| 55 | repository: None, | |
| 56 | }, | |
| 57 | bypass_actors: Vec::new(), | |
| 58 | rules, | |
| 59 | }) | |
| 60 | } | |
| 61 | ||
| 62 | /// A ruleset's rules with the branch protection kinds (pull request, | |
| 63 | /// status checks, merge queue) replaced by what `settings` say, the others | |
| 64 | /// kept as they were. | |
| 65 | pub fn replace(existing: &[RuleEntry], settings: &RepoSettings, protected: bool) -> Vec<RuleEntry> { | |
| 66 | let mut rules: Vec<RuleEntry> = existing | |
| 67 | .iter() | |
| 68 | .filter(|entry| { | |
| 69 | !matches!(entry.rule, Rule::PullRequest(_) | Rule::RequiredStatusChecks(_) | Rule::MergeQueue(_)) | |
| 70 | || entry.applies_to != g1t_contracts::rules::AppliesTo::Everyone | |
| 71 | }) | |
| 72 | .cloned() | |
| 73 | .collect(); | |
| 74 | let mut fresh = rules_of(settings, protected); | |
| 75 | // Keep what the old settings did not have a say in. | |
| 76 | for entry in &mut fresh { | |
| 77 | if let (Rule::PullRequest(new), Some(Rule::PullRequest(old))) = ( | |
| 78 | &mut entry.rule, | |
| 79 | existing.iter().map(|entry| &entry.rule).find(|rule| matches!(rule, Rule::PullRequest(_))), | |
| 80 | ) { | |
| 81 | new.dismiss_stale_reviews_on_push = old.dismiss_stale_reviews_on_push; | |
| 82 | new.require_last_push_approval = old.require_last_push_approval; | |
| 83 | new.allowed_merge_methods = old.allowed_merge_methods.clone(); | |
| 84 | } | |
| 85 | if let (Rule::RequiredStatusChecks(new), Some(Rule::RequiredStatusChecks(old))) = ( | |
| 86 | &mut entry.rule, | |
| 87 | existing.iter().map(|entry| &entry.rule).find(|rule| matches!(rule, Rule::RequiredStatusChecks(_))), | |
| 88 | ) { | |
| 89 | for check in &mut new.checks { | |
| 90 | check.integration = old | |
| 91 | .checks | |
| 92 | .iter() | |
| 93 | .find(|was| was.context.eq_ignore_ascii_case(&check.context)) | |
| 94 | .and_then(|was| was.integration); | |
| 95 | } | |
| 96 | } | |
| 97 | if let (Rule::MergeQueue(new), Some(Rule::MergeQueue(old))) = ( | |
| 98 | &mut entry.rule, | |
| 99 | existing.iter().map(|entry| &entry.rule).find(|rule| matches!(rule, Rule::MergeQueue(_))), | |
| 100 | ) { | |
| 101 | *new = old.clone(); | |
| 102 | } | |
| 103 | } | |
| 104 | let mut out = fresh; | |
| 105 | out.append(&mut rules); | |
| 106 | out | |
| 107 | } | |
| 108 | ||
| 109 | /// Whether the ruleset's pull request rule refuses pushes: what the | |
| 110 | /// repository's old `protected` flag said. | |
| 111 | pub fn requires_pull_requests(rules: &[RuleEntry]) -> bool { | |
| 112 | rules.iter().any(|entry| { | |
| 113 | entry.applies_to == g1t_contracts::rules::AppliesTo::Everyone | |
| 114 | && matches!(&entry.rule, Rule::PullRequest(rule) if !rule.allow_direct_pushes) | |
| 115 | }) | |
| 116 | } | |
| 117 | ||
| 118 | #[cfg(test)] | |
| 119 | mod tests { | |
| 120 | use super::*; | |
| 121 | use crate::merge::requirements; | |
| 122 | use crate::push::{RefChange, judge}; | |
| 123 | use crate::select::Who; | |
| 124 | use g1t_contracts::rules::{Applicable, Level, NoParameters}; | |
| 125 | ||
| 126 | fn applicable(spec: &RulesetSpec) -> Applicable { | |
| 127 | Applicable { | |
| 128 | id: "rs_bp".into(), | |
| 129 | name: spec.name.clone(), | |
| 130 | level: Level::Repository, | |
| 131 | enforcement: spec.enforcement, | |
| 132 | target: spec.target, | |
| 133 | conditions: spec.conditions.ref_name.clone(), | |
| 134 | rules: spec.rules.clone(), | |
| 135 | bypass: None, | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | fn push_to(branch: &str) -> RefChange { | |
| 140 | RefChange { | |
| 141 | git_ref: format!("refs/heads/{branch}"), | |
| 142 | old: Some("a".repeat(40)), | |
| 143 | new: Some("b".repeat(40)), | |
| 144 | fast_forward: Some(true), | |
| 145 | commits: Vec::new(), | |
| 146 | complete: true, | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | #[test] | |
| 151 | fn nothing_protected_makes_no_ruleset() { | |
| 152 | assert_eq!(ruleset_of(&RepoSettings::default(), false), None); | |
| 153 | } | |
| 154 | ||
| 155 | #[test] | |
| 156 | fn protection_becomes_a_ruleset_that_behaves_as_it_did() { | |
| 157 | let settings = RepoSettings { | |
| 158 | required_checks: vec!["CI".into()], | |
| 159 | require_up_to_date: true, | |
| 160 | required_approvals: 2, | |
| 161 | count_agent_approvals: false, | |
| 162 | allow_ignoring_checks: false, | |
| 163 | merge_queue: true, | |
| 164 | require_code_owner_review: true, | |
| 165 | ..RepoSettings::default() | |
| 166 | }; | |
| 167 | let spec = ruleset_of(&settings, true).unwrap(); | |
| 168 | assert_eq!(spec.name, "Default branch protection"); | |
| 169 | assert_eq!(spec.conditions.ref_name.include, vec!["~DEFAULT_BRANCH"]); | |
| 170 | let rules = [applicable(&spec)]; | |
| 171 | // The same requirements on the default branch... | |
| 172 | let found = requirements(&rules, "refs/heads/main", "main", false, &[]); | |
| 173 | assert_eq!(found.required_checks, vec!["CI"]); | |
| 174 | assert!(found.strict && !found.allow_bypass_on_merge && found.require_code_owner_review && !found.count_agent_approvals); | |
| 175 | assert_eq!(found.required_approvals, 2); | |
| 176 | assert!(found.merge_queue.is_some()); | |
| 177 | // ...none on another branch... | |
| 178 | assert_eq!(requirements(&rules, "refs/heads/release", "main", false, &[]).required_approvals, 0); | |
| 179 | // ...and pushes to it refused, as protection refused them. | |
| 180 | assert!(crate::outcome::refused(&judge(&rules, "main", Who::Person, &push_to("main")))); | |
| 181 | assert!(judge(&rules, "main", Who::Person, &push_to("release")).is_empty()); | |
| 182 | assert!(requires_pull_requests(&spec.rules)); | |
| 183 | } | |
| 184 | ||
| 185 | #[test] | |
| 186 | fn approvals_without_protection_still_let_pushes_through() { | |
| 187 | let settings = RepoSettings { required_approvals: 1, ..RepoSettings::default() }; | |
| 188 | let spec = ruleset_of(&settings, false).unwrap(); | |
| 189 | let rules = [applicable(&spec)]; | |
| 190 | assert!(!crate::outcome::refused(&judge(&rules, "main", Who::Person, &push_to("main")))); | |
| 191 | assert_eq!(requirements(&rules, "refs/heads/main", "main", false, &[]).required_approvals, 1); | |
| 192 | assert!(!requires_pull_requests(&spec.rules)); | |
| 193 | // Protection alone is a pull request rule with nothing else asked. | |
| 194 | let only = ruleset_of(&RepoSettings::default(), true).unwrap(); | |
| 195 | assert_eq!(only.rules.len(), 1); | |
| 196 | assert!(requires_pull_requests(&only.rules)); | |
| 197 | } | |
| 198 | ||
| 199 | /// What services/work/migrations/0028_rulesets.sql writes for a | |
| 200 | /// repository with every setting on (run against SQLite), read back: | |
| 201 | /// the same ruleset this module makes. | |
| 202 | #[test] | |
| 203 | fn the_migration_writes_what_this_module_makes() { | |
| 204 | let migrated: RulesetSpec = serde_json::from_str(r#"{"bypass_actors": [], "conditions": {"ref_name": {"exclude": [], "include": ["~DEFAULT_BRANCH"]}}, "enforcement": "active", "name": "Default branch protection", "rules": [{"applies_to": "everyone", "parameters": {"allow_direct_pushes": true, "allowed_merge_methods": [], "count_agent_approvals": false, "dismiss_stale_reviews_on_push": false, "require_code_owner_review": true, "require_last_push_approval": false, "required_approvals": 2}, "type": "pull_request"}, {"applies_to": "everyone", "parameters": {"allow_bypass_on_merge": false, "checks": [{"context": "CI"}, {"context": "Lint"}], "paths": [], "strict": true}, "type": "required_status_checks"}, {"applies_to": "everyone", "parameters": {"check_response_timeout_minutes": 45, "max_entries_to_build": 4, "merge_method": "merge", "min_entries_to_merge": 1, "min_entries_wait_minutes": 0}, "type": "merge_queue"}], "target": "branch"}"#).unwrap(); | |
| 205 | let settings = RepoSettings { | |
| 206 | required_approvals: 2, | |
| 207 | count_agent_approvals: false, | |
| 208 | require_code_owner_review: true, | |
| 209 | required_checks: vec!["CI".into(), "Lint".into()], | |
| 210 | require_up_to_date: true, | |
| 211 | allow_ignoring_checks: false, | |
| 212 | merge_queue: true, | |
| 213 | ..RepoSettings::default() | |
| 214 | }; | |
| 215 | assert_eq!(Some(migrated), ruleset_of(&settings, false)); | |
| 216 | } | |
| 217 | ||
| 218 | #[test] | |
| 219 | fn settings_written_later_replace_only_their_own_rules() { | |
| 220 | let spec = ruleset_of(&RepoSettings { required_approvals: 1, ..RepoSettings::default() }, true).unwrap(); | |
| 221 | let mut rules = spec.rules.clone(); | |
| 222 | rules.push(RuleEntry::everyone(Rule::NonFastForward(NoParameters {}))); | |
| 223 | if let Rule::PullRequest(rule) = &mut rules[0].rule { | |
| 224 | rule.dismiss_stale_reviews_on_push = true; | |
| 225 | } | |
| 226 | let changed = replace(&rules, &RepoSettings { required_approvals: 3, required_checks: vec!["CI".into()], ..RepoSettings::default() }, true); | |
| 227 | let kinds: Vec<&str> = changed.iter().map(|entry| entry.rule.kind()).collect(); | |
| 228 | assert_eq!(kinds, vec!["pull_request", "required_status_checks", "non_fast_forward"]); | |
| 229 | let Rule::PullRequest(pull) = &changed[0].rule else { panic!() }; | |
| 230 | assert_eq!(pull.required_approvals, 3); | |
| 231 | assert!(pull.dismiss_stale_reviews_on_push, "what the settings never had stays"); | |
| 232 | } | |
| 233 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.