| 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 | } |