Skip to content

g1t/crates/rules/src/legacy.rs

233 lines11,102 bytesCodeBlame

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 merge1//! 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
6use g1t_contracts::rules::{
7 Conditions, DEFAULT_BRANCH, Enforcement, MergeQueueRule, PullRequestRule, RefCondition, RequiredCheck, Rule,
8 RuleEntry, RulesetSpec, StatusChecksRule, Target,
9};
10use g1t_contracts::work::RepoSettings;
11
12/// The name the ruleset made from branch protection is given.
13pub const NAME: &str = "Default branch protection";
14
15/// What branch protection held, as rules. `protected`: pushes to the
16/// default branch were refused.
17pub 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.
47pub 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.
65pub 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.
111pub 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)]
119mod 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.