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 | //! Judging a change to a branch or tag that does not come from merging a |
| 2 | //! pull request: a push, a branch created, deleted or renamed through g1t, | |
| 3 | //! or a commit made on the site. | |
| 4 | ||
| 5 | use g1t_contracts::rules::{Applicable, CommitFacts, Rule, Target}; | |
| 6 | ||
| 7 | use crate::content::{self, Problem}; | |
| 8 | use crate::outcome::Judged; | |
| 9 | use crate::select::{Who, applies_to_ref}; | |
| 10 | use crate::text; | |
| 11 | ||
| 12 | /// One ref a change moves. | |
| 13 | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 14 | pub struct RefChange { | |
| 15 | /// The full ref. | |
| 16 | pub git_ref: String, | |
| 17 | /// Where it pointed; `None` when it is created. | |
| 18 | pub old: Option<String>, | |
| 19 | /// Where it will; `None` when it is deleted. | |
| 20 | pub new: Option<String>, | |
| 21 | /// For an update: whether `new` contains `old`. `None` if unknown. | |
| 22 | pub fast_forward: Option<bool>, | |
| 23 | /// The commits the change adds to the ref, newest first. | |
| 24 | pub commits: Vec<CommitFacts>, | |
| 25 | /// Whether `commits` is every commit it adds, each read in full. | |
| 26 | pub complete: bool, | |
| 27 | } | |
| 28 | ||
| 29 | impl RefChange { | |
| 30 | fn created(&self) -> bool { | |
| 31 | self.old.is_none() && self.new.is_some() | |
| 32 | } | |
| 33 | ||
| 34 | fn deleted(&self) -> bool { | |
| 35 | self.new.is_none() | |
| 36 | } | |
| 37 | ||
| 38 | fn updated(&self) -> bool { | |
| 39 | self.old.is_some() && self.new.is_some() | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | fn short_name(git_ref: &str) -> &str { | |
| 44 | Target::of_ref(git_ref).map_or(git_ref, |(_, name)| name) | |
| 45 | } | |
| 46 | ||
| 47 | /// The problems one rule finds in a change, for an actor of kind `who`. | |
| 48 | fn rule_problems(rule: &Rule, change: &RefChange) -> Vec<Problem> { | |
| 49 | let name = short_name(&change.git_ref); | |
| 50 | let tag = change.git_ref.starts_with("refs/tags/"); | |
| 51 | let what = if tag { "tag" } else { "branch" }; | |
| 52 | match rule { | |
| 53 | Rule::Creation(_) if change.created() => vec![Problem::new( | |
| 54 | format!("Only people this ruleset lets bypass it may create the {what} {name}."), | |
| 55 | format!("Use a {what} name the ruleset does not cover, or ask someone who may bypass it."), | |
| 56 | )], | |
| 57 | Rule::Update(_) if change.updated() => vec![Problem::new( | |
| 58 | format!("Only people this ruleset lets bypass it may push to {name}."), | |
| 59 | "Ask someone who may bypass it, or change it through a pull request.", | |
| 60 | )], | |
| 61 | Rule::Deletion(_) if change.deleted() => vec![Problem::new( | |
| 62 | format!("The {what} {name} cannot be deleted."), | |
| 63 | "Ask someone who may bypass this ruleset.", | |
| 64 | )], | |
| 65 | Rule::NonFastForward(_) if change.updated() && change.fast_forward == Some(false) => vec![Problem::new( | |
| 66 | format!("Force pushes to {name} are blocked: the push would rewrite its history."), | |
| 67 | format!("Pull {name}, put your commits on top of it, and push without --force."), | |
| 68 | )], | |
| 69 | Rule::PullRequest(rule) if change.updated() && !tag && !rule.allow_direct_pushes => vec![Problem::new( | |
| 70 | format!("Changes to {name} must be made through a pull request."), | |
| 71 | format!("Push a branch, open a pull request into {name}, and merge it."), | |
| 72 | )], | |
| 73 | Rule::BranchNamePattern(pattern) | Rule::TagNamePattern(pattern) if change.created() => { | |
| 74 | match text::compile(pattern) { | |
| 75 | Ok(compiled) if !compiled.allows(name) => vec![Problem::new( | |
| 76 | format!("The {what} name {name} does not {}.", compiled.wants()), | |
| 77 | format!("Name the {what} so it does {}.", compiled.wants().trim_start_matches("not ")), | |
| 78 | )], | |
| 79 | _ => Vec::new(), | |
| 80 | } | |
| 81 | } | |
| 82 | rule if content::about_content(rule) && !change.deleted() => { | |
| 83 | content::problems(rule, &change.commits, change.complete) | |
| 84 | } | |
| 85 | _ => Vec::new(), | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | /// How every applicable ruleset judges one ref change by an actor of kind | |
| 90 | /// `who`. Rulesets that do not hold for its ref are left out. | |
| 91 | pub fn judge(rulesets: &[Applicable], default_branch: &str, who: Who, change: &RefChange) -> Vec<Judged> { | |
| 92 | rulesets | |
| 93 | .iter() | |
| 94 | .filter(|ruleset| applies_to_ref(ruleset, &change.git_ref, default_branch)) | |
| 95 | .map(|ruleset| { | |
| 96 | let mut judged = Judged::of(ruleset, &change.git_ref, false); | |
| 97 | for entry in &ruleset.rules { | |
| 98 | if !entry.applies_to.covers(who.is_agent()) { | |
| 99 | continue; | |
| 100 | } | |
| 101 | let kind = entry.rule.kind(); | |
| 102 | for problem in rule_problems(&entry.rule, change) { | |
| 103 | judged.add(kind, problem); | |
| 104 | } | |
| 105 | } | |
| 106 | judged | |
| 107 | }) | |
| 108 | .collect() | |
| 109 | } | |
| 110 | ||
| 111 | /// Whether any ruleset that is not bypassed has a rule that needs a | |
| 112 | /// change's commits read: if none, a push need not be parsed for rules. | |
| 113 | pub fn needs_content(rulesets: &[Applicable], who: Who) -> bool { | |
| 114 | rulesets.iter().filter(|ruleset| ruleset.bypass.is_none()).any(|ruleset| { | |
| 115 | ruleset | |
| 116 | .rules | |
| 117 | .iter() | |
| 118 | .any(|entry| entry.applies_to.covers(who.is_agent()) && content::about_content(&entry.rule)) | |
| 119 | }) | |
| 120 | } | |
| 121 | ||
| 122 | /// Whether any ruleset asks for every push to be read whole. | |
| 123 | pub fn requires_scanning(rulesets: &[Applicable], who: Who) -> bool { | |
| 124 | rulesets.iter().any(|ruleset| { | |
| 125 | ruleset | |
| 126 | .rules | |
| 127 | .iter() | |
| 128 | .any(|entry| entry.applies_to.covers(who.is_agent()) && matches!(entry.rule, Rule::SecretScanning(_))) | |
| 129 | }) | |
| 130 | } | |
| 131 | ||
| 132 | /// Whether any rule asks for signatures to be verified. | |
| 133 | pub fn needs_signatures(rulesets: &[Applicable]) -> bool { | |
| 134 | rulesets | |
| 135 | .iter() | |
| 136 | .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::RequiredSignatures(_)))) | |
| 137 | } | |
| 138 | ||
| 139 | #[cfg(test)] | |
| 140 | mod tests { | |
| 141 | use super::*; | |
| 142 | use crate::outcome::{blocking, refused, would_block}; | |
| 143 | use g1t_contracts::rules::{ | |
| 144 | AppliesTo, BypassMode, Enforcement, FilePathRule, Level, NoParameters, PatternOperator, PatternRule, | |
| 145 | PullRequestRule, RefCondition, RuleEntry, Verdict, | |
| 146 | }; | |
| 147 | ||
| 148 | fn ruleset(id: &str, include: &[&str], rules: Vec<RuleEntry>) -> Applicable { | |
| 149 | Applicable { | |
| 150 | id: id.into(), | |
| 151 | name: format!("Ruleset {id}"), | |
| 152 | level: Level::Repository, | |
| 153 | enforcement: Enforcement::Active, | |
| 154 | target: if include.iter().any(|p| p.starts_with("v")) { Target::Tag } else { Target::Branch }, | |
| 155 | conditions: RefCondition { include: include.iter().map(|p| (*p).to_owned()).collect(), exclude: Vec::new() }, | |
| 156 | rules, | |
| 157 | bypass: None, | |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | fn all(rule: Rule) -> RuleEntry { | |
| 162 | RuleEntry::everyone(rule) | |
| 163 | } | |
| 164 | ||
| 165 | fn update(git_ref: &str) -> RefChange { | |
| 166 | RefChange { | |
| 167 | git_ref: git_ref.into(), | |
| 168 | old: Some("a".repeat(40)), | |
| 169 | new: Some("b".repeat(40)), | |
| 170 | fast_forward: Some(true), | |
| 171 | commits: Vec::new(), | |
| 172 | complete: true, | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | #[test] | |
| 177 | fn a_protected_branch_takes_changes_only_through_pull_requests() { | |
| 178 | let protect = ruleset("main", &["~DEFAULT_BRANCH"], vec![all(Rule::PullRequest(PullRequestRule::default()))]); | |
| 179 | let judged = judge(std::slice::from_ref(&protect), "main", Who::Person, &update("refs/heads/main")); | |
| 180 | assert!(refused(&judged)); | |
| 181 | assert_eq!(blocking(&judged)[0].message, "Changes to main must be made through a pull request."); | |
| 182 | assert_eq!(blocking(&judged)[0].rule, "pull_request"); | |
| 183 | // Creating it, as the first push to an empty repository does, is allowed. | |
| 184 | let created = RefChange { old: None, ..update("refs/heads/main") }; | |
| 185 | assert!(!refused(&judge(std::slice::from_ref(&protect), "main", Who::Person, &created))); | |
| 186 | // Another branch is not covered. | |
| 187 | assert!(judge(&[protect], "main", Who::Person, &update("refs/heads/feature")).is_empty()); | |
| 188 | } | |
| 189 | ||
| 190 | #[test] | |
| 191 | fn creations_deletions_and_force_pushes() { | |
| 192 | let guard = ruleset( | |
| 193 | "r", | |
| 194 | &["release/*"], | |
| 195 | vec![all(Rule::Creation(NoParameters {})), all(Rule::Deletion(NoParameters {})), all(Rule::NonFastForward(NoParameters {}))], | |
| 196 | ); | |
| 197 | let created = RefChange { old: None, ..update("refs/heads/release/2") }; | |
| 198 | assert_eq!(blocking(&judge(std::slice::from_ref(&guard), "main", Who::Person, &created))[0].rule, "creation"); | |
| 199 | let deleted = RefChange { new: None, ..update("refs/heads/release/2") }; | |
| 200 | assert_eq!(blocking(&judge(std::slice::from_ref(&guard), "main", Who::Person, &deleted))[0].rule, "deletion"); | |
| 201 | let forced = RefChange { fast_forward: Some(false), ..update("refs/heads/release/2") }; | |
| 202 | let judged = judge(std::slice::from_ref(&guard), "main", Who::Person, &forced); | |
| 203 | assert_eq!(blocking(&judged)[0].message, "Force pushes to release/2 are blocked: the push would rewrite its history."); | |
| 204 | assert!(!refused(&judge(&[guard], "main", Who::Person, &update("refs/heads/release/2")))); | |
| 205 | } | |
| 206 | ||
| 207 | #[test] | |
| 208 | fn a_bypass_lets_the_push_through_and_is_recorded_as_one() { | |
| 209 | let mut protect = ruleset("main", &["main"], vec![all(Rule::Update(NoParameters {}))]); | |
| 210 | protect.bypass = Some(BypassMode::Always); | |
| 211 | let judged = judge(&[protect.clone()], "main", Who::Person, &update("refs/heads/main")); | |
| 212 | assert!(!refused(&judged)); | |
| 213 | assert_eq!(judged[0].verdict(), Verdict::Bypass); | |
| 214 | // A bypass for pull requests only does not cover a push. | |
| 215 | protect.bypass = Some(BypassMode::PullRequests); | |
| 216 | assert!(refused(&judge(&[protect], "main", Who::Person, &update("refs/heads/main")))); | |
| 217 | } | |
| 218 | ||
| 219 | #[test] | |
| 220 | fn evaluate_mode_records_without_refusing() { | |
| 221 | let mut dry = ruleset("dry", &["~ALL"], vec![all(Rule::NonFastForward(NoParameters {}))]); | |
| 222 | dry.enforcement = Enforcement::Evaluate; | |
| 223 | let forced = RefChange { fast_forward: Some(false), ..update("refs/heads/feature") }; | |
| 224 | let judged = judge(&[dry], "main", Who::Person, &forced); | |
| 225 | assert!(!refused(&judged)); | |
| 226 | assert_eq!(would_block(&judged).len(), 1); | |
| 227 | assert_eq!(judged[0].verdict(), Verdict::Fail); | |
| 228 | } | |
| 229 | ||
| 230 | #[test] | |
| 231 | fn rules_for_agents_hold_only_for_agents() { | |
| 232 | let agents_only = RuleEntry { | |
| 233 | rule: Rule::FilePathRestriction(FilePathRule { restricted_file_paths: vec![".g1t/workflows/**".into(), "CODEOWNERS".into()] }), | |
| 234 | applies_to: AppliesTo::Agents, | |
| 235 | }; | |
| 236 | let workflows = ruleset("w", &["~ALL"], vec![agents_only]); | |
| 237 | let mut change = update("refs/heads/feature"); | |
| 238 | change.commits = vec![crate::content::tests_support::commit("c1", "x", &[".g1t/workflows/deploy.yml"])]; | |
| 239 | assert!(refused(&judge(std::slice::from_ref(&workflows), "main", Who::Agent, &change))); | |
| 240 | assert!(refused(&judge(std::slice::from_ref(&workflows), "main", Who::G1t, &change))); | |
| 241 | assert!(!refused(&judge(&[workflows], "main", Who::Person, &change))); | |
| 242 | } | |
| 243 | ||
| 244 | #[test] | |
| 245 | fn names_of_new_branches_and_tags_follow_their_patterns() { | |
| 246 | let branches = ruleset( | |
| 247 | "n", | |
| 248 | &["~ALL"], | |
| 249 | vec![all(Rule::BranchNamePattern(PatternRule { | |
| 250 | name: String::new(), | |
| 251 | operator: PatternOperator::Regex, | |
| 252 | pattern: "^(main|(feature|fix)/.+)$".into(), | |
| 253 | negate: false, | |
| 254 | }))], | |
| 255 | ); | |
| 256 | let bad = RefChange { old: None, ..update("refs/heads/stuff") }; | |
| 257 | assert_eq!( | |
| 258 | blocking(&judge(std::slice::from_ref(&branches), "main", Who::Person, &bad))[0].message, | |
| 259 | "The branch name stuff does not match /^(main|(feature|fix)/.+)$/." | |
| 260 | ); | |
| 261 | let good = RefChange { old: None, ..update("refs/heads/feature/rules") }; | |
| 262 | assert!(!refused(&judge(std::slice::from_ref(&branches), "main", Who::Person, &good))); | |
| 263 | // Pushing to an existing branch is not naming it. | |
| 264 | assert!(!refused(&judge(&[branches], "main", Who::Person, &update("refs/heads/stuff")))); | |
| 265 | let tags = ruleset( | |
| 266 | "t", | |
| 267 | &["v*", "~ALL"], | |
| 268 | vec![all(Rule::TagNamePattern(PatternRule { | |
| 269 | name: "Semantic versions".into(), | |
| 270 | operator: PatternOperator::Regex, | |
| 271 | pattern: r"^v\d+\.\d+\.\d+$".into(), | |
| 272 | negate: false, | |
| 273 | }))], | |
| 274 | ); | |
| 275 | let tag = RefChange { old: None, ..update("refs/tags/v1") }; | |
| 276 | assert!(refused(&judge(&[tags], "main", Who::Person, &tag))); | |
| 277 | } | |
| 278 | ||
| 279 | #[test] | |
| 280 | fn content_rules_need_the_change_read_whole() { | |
| 281 | let signed = ruleset("s", &["~ALL"], vec![all(Rule::RequiredSignatures(NoParameters {}))]); | |
| 282 | assert!(needs_content(std::slice::from_ref(&signed), Who::Person)); | |
| 283 | assert!(needs_signatures(std::slice::from_ref(&signed))); | |
| 284 | let unread = RefChange { complete: false, ..update("refs/heads/feature") }; | |
| 285 | assert_eq!( | |
| 286 | blocking(&judge(std::slice::from_ref(&signed), "main", Who::Person, &unread))[0].message, | |
| 287 | "The change is too large for g1t to check against this rule." | |
| 288 | ); | |
| 289 | let mut bypassed = signed; | |
| 290 | bypassed.bypass = Some(BypassMode::Always); | |
| 291 | assert!(!needs_content(&[bypassed], Who::Person), "a bypass actor's push need not be read"); | |
| 292 | let scan = ruleset("x", &["~ALL"], vec![all(Rule::SecretScanning(NoParameters {}))]); | |
| 293 | assert!(requires_scanning(&[scan], Who::Agent)); | |
| 294 | } | |
| 295 | ||
| 296 | #[test] | |
| 297 | fn deleting_a_branch_checks_no_commits() { | |
| 298 | let signed = ruleset("s", &["~ALL"], vec![all(Rule::RequiredSignatures(NoParameters {}))]); | |
| 299 | let deleted = RefChange { new: None, complete: false, ..update("refs/heads/feature") }; | |
| 300 | assert!(!refused(&judge(&[signed], "main", Who::Person, &deleted))); | |
| 301 | } | |
| 302 | ||
| 303 | #[test] | |
| 304 | fn several_rulesets_stack() { | |
| 305 | let a = ruleset("a", &["main"], vec![all(Rule::NonFastForward(NoParameters {}))]); | |
| 306 | let b = ruleset("b", &["~ALL"], vec![all(Rule::PullRequest(PullRequestRule::default()))]); | |
| 307 | let forced = RefChange { fast_forward: Some(false), ..update("refs/heads/main") }; | |
| 308 | let judged = judge(&[a, b], "main", Who::Person, &forced); | |
| 309 | let rules: Vec<&str> = blocking(&judged).iter().map(|violation| violation.rule.as_str()).collect(); | |
| 310 | assert_eq!(rules, vec!["non_fast_forward", "pull_request"]); | |
| 311 | } | |
| 312 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.