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 | //! Which rulesets hold where, who may bypass them, and what holds for one |
| 2 | //! branch once they are stacked. | |
| 3 | ||
| 4 | use g1t_contracts::access::{self, RepoRole}; | |
| 5 | use g1t_contracts::repos::Repo; | |
| 6 | use g1t_contracts::rules::{ | |
| 7 | ALL, ActorKind, Applicable, BypassActor, BypassMode, DEFAULT_BRANCH, EffectiveRule, EffectiveRules, Enforcement, | |
| 8 | Level, RefCondition, RepositoryCondition, Ruleset, RulesetSummary, Target, VisibilityCondition, | |
| 9 | }; | |
| 10 | use g1t_contracts::{PrincipalKind, Role, User}; | |
| 11 | ||
| 12 | use crate::glob; | |
| 13 | ||
| 14 | /// What a ruleset needs to know about a repository to say whether it holds. | |
| 15 | #[derive(Clone, Copy, Debug)] | |
| 16 | pub struct RepoFacts<'a> { | |
| 17 | pub id: &'a str, | |
| 18 | pub name: &'a str, | |
| 19 | pub private: bool, | |
| 20 | pub topics: &'a [String], | |
| 21 | pub default_branch: &'a str, | |
| 22 | } | |
| 23 | ||
| 24 | impl<'a> From<&'a Repo> for RepoFacts<'a> { | |
| 25 | fn from(repo: &'a Repo) -> Self { | |
| 26 | RepoFacts { | |
| 27 | id: &repo.id, | |
| 28 | name: &repo.name, | |
| 29 | private: repo.is_private, | |
| 30 | topics: &repo.topics, | |
| 31 | default_branch: &repo.default_branch, | |
| 32 | } | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | /// A name written as a full ref, shortened: `refs/heads/main` is `main`. | |
| 37 | fn short(pattern: &str, target: Target) -> &str { | |
| 38 | let prefix = match target { | |
| 39 | Target::Branch => "refs/heads/", | |
| 40 | Target::Tag => "refs/tags/", | |
| 41 | }; | |
| 42 | pattern.strip_prefix(prefix).unwrap_or(pattern) | |
| 43 | } | |
| 44 | ||
| 45 | fn ref_pattern_matches(pattern: &str, target: Target, name: &str, default_branch: &str) -> bool { | |
| 46 | let pattern = pattern.trim(); | |
| 47 | match pattern { | |
| 48 | ALL => true, | |
| 49 | DEFAULT_BRANCH => target == Target::Branch && name == default_branch, | |
| 50 | _ => glob::matches(short(pattern, target), name), | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | /// Whether a branch or tag named `name` is one `condition` selects. | |
| 55 | pub fn ref_matches(condition: &RefCondition, target: Target, name: &str, default_branch: &str) -> bool { | |
| 56 | condition.include.iter().any(|pattern| ref_pattern_matches(pattern, target, name, default_branch)) | |
| 57 | && !condition.exclude.iter().any(|pattern| ref_pattern_matches(pattern, target, name, default_branch)) | |
| 58 | } | |
| 59 | ||
| 60 | /// Whether a workspace ruleset's repository condition selects `repo`. | |
| 61 | pub fn repo_matches(condition: &RepositoryCondition, repo: RepoFacts<'_>) -> bool { | |
| 62 | let name = repo.name.to_lowercase(); | |
| 63 | let named = |pattern: &String| { | |
| 64 | let pattern = pattern.trim(); | |
| 65 | pattern == ALL || glob::matches(&pattern.to_lowercase(), &name) | |
| 66 | }; | |
| 67 | let visible = match condition.visibility { | |
| 68 | VisibilityCondition::Any => true, | |
| 69 | VisibilityCondition::Public => !repo.private, | |
| 70 | VisibilityCondition::Private => repo.private, | |
| 71 | }; | |
| 72 | let topical = condition.topics.is_empty() | |
| 73 | || condition | |
| 74 | .topics | |
| 75 | .iter() | |
| 76 | .any(|topic| repo.topics.iter().any(|has| has.eq_ignore_ascii_case(topic.trim()))); | |
| 77 | condition.include.iter().any(named) && !condition.exclude.iter().any(named) && visible && topical | |
| 78 | } | |
| 79 | ||
| 80 | /// Whether a ruleset holds in `repo` at all, whatever the branch: a | |
| 81 | /// repository's own, or a workspace's that selects it. Disabled ones never. | |
| 82 | pub fn holds_in(ruleset: &Ruleset, repo: RepoFacts<'_>) -> bool { | |
| 83 | if ruleset.spec.enforcement == Enforcement::Disabled { | |
| 84 | return false; | |
| 85 | } | |
| 86 | match ruleset.level { | |
| 87 | Level::Repository => ruleset.repo_id.as_deref() == Some(repo.id), | |
| 88 | Level::Workspace => { | |
| 89 | let condition = ruleset.spec.conditions.repository.clone().unwrap_or_default(); | |
| 90 | repo_matches(&condition, repo) | |
| 91 | } | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | /// Who is changing something, as bypass lists name people. | |
| 96 | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 97 | pub struct ActorFacts { | |
| 98 | pub username: String, | |
| 99 | pub kind: Who, | |
| 100 | /// Their role on the repository. | |
| 101 | pub role: Option<RepoRole>, | |
| 102 | /// Whether they own its workspace. | |
| 103 | pub owner: bool, | |
| 104 | /// The teams they are in, as `workspace/slug`, lowercase. | |
| 105 | pub teams: Vec<String>, | |
| 106 | /// The token they act through, if any. | |
| 107 | pub token_id: Option<String>, | |
| 108 | } | |
| 109 | ||
| 110 | /// What kind of actor. | |
| 111 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 112 | pub enum Who { | |
| 113 | #[default] | |
| 114 | Person, | |
| 115 | /// An agent acting through a token, g1t's or another. | |
| 116 | Agent, | |
| 117 | /// g1t acting on its own: the merge queue, security updates. | |
| 118 | G1t, | |
| 119 | /// A workspace's own token. | |
| 120 | Token, | |
| 121 | } | |
| 122 | ||
| 123 | impl Who { | |
| 124 | pub fn as_str(self) -> &'static str { | |
| 125 | match self { | |
| 126 | Who::Person => "person", | |
| 127 | Who::Agent => "agent", | |
| 128 | Who::G1t => "g1t", | |
| 129 | Who::Token => "token", | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | /// Rules for agents hold for agents and g1t; the rest are people's. | |
| 134 | pub fn is_agent(self) -> bool { | |
| 135 | matches!(self, Who::Agent | Who::G1t) | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | impl ActorFacts { | |
| 140 | /// What `user` is in `repo`. Their teams are the caller's to add. | |
| 141 | pub fn of(user: &User, repo: &Repo) -> ActorFacts { | |
| 142 | let kind = match user.kind { | |
| 143 | PrincipalKind::System => Who::G1t, | |
| 144 | PrincipalKind::Agent => Who::Agent, | |
| 145 | _ if user.acting.is_some() => Who::Agent, | |
| 146 | PrincipalKind::Workspace => Who::Token, | |
| 147 | PrincipalKind::User => Who::Person, | |
| 148 | }; | |
| 149 | let token_id = user | |
| 150 | .acting | |
| 151 | .as_ref() | |
| 152 | .map(|acting| acting.credential_id.clone()) | |
| 153 | .or_else(|| user.token.as_ref().map(|token| token.token_id.clone())) | |
| 154 | .filter(|id| !id.is_empty()); | |
| 155 | ActorFacts { | |
| 156 | username: user.username.clone(), | |
| 157 | kind, | |
| 158 | role: access::permission(Some(user), repo), | |
| 159 | owner: user.role_in(&repo.namespace.to_lowercase()) == Some(Role::Owner), | |
| 160 | teams: Vec::new(), | |
| 161 | token_id, | |
| 162 | } | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | /// A team named in a bypass list or a rule, as `workspace/slug`. | |
| 167 | pub fn team_key(name: &str, workspace: &str) -> String { | |
| 168 | let name = name.trim().trim_start_matches('@').to_lowercase(); | |
| 169 | if name.contains('/') { name } else { format!("{}/{name}", workspace.to_lowercase()) } | |
| 170 | } | |
| 171 | ||
| 172 | /// Whether one bypass entry names the actor. An agent or a token is never | |
| 173 | /// named by a role, a team or a person: only by `g1t` or its token, so an | |
| 174 | /// agent acting for an admin obeys the rules its admin may bypass. | |
| 175 | fn names(entry: &BypassActor, who: &ActorFacts, workspace: &str) -> bool { | |
| 176 | let value = entry.value.trim(); | |
| 177 | match entry.kind { | |
| 178 | // g1t's agents act through run tokens (`Agent`), and g1t on its own | |
| 179 | // as `System`. | |
| 180 | ActorKind::G1t => who.kind.is_agent(), | |
| 181 | ActorKind::Token => { | |
| 182 | (value.eq_ignore_ascii_case("workspace") && who.kind == Who::Token) | |
| 183 | || who.token_id.as_deref().is_some_and(|id| !value.is_empty() && id == value) | |
| 184 | } | |
| 185 | _ if who.kind != Who::Person => false, | |
| 186 | ActorKind::User => !value.is_empty() && who.username.eq_ignore_ascii_case(value.trim_start_matches('@')), | |
| 187 | ActorKind::Team => !value.is_empty() && who.teams.contains(&team_key(value, workspace)), | |
| 188 | ActorKind::Role => match value.to_ascii_lowercase().as_str() { | |
| 189 | "owner" => who.owner, | |
| 190 | role => RepoRole::parse(role).is_some_and(|wanted| who.role.is_some_and(|has| has >= wanted)), | |
| 191 | }, | |
| 192 | } | |
| 193 | } | |
| 194 | ||
| 195 | /// How the actor may bypass a ruleset with these bypass actors, if at all. | |
| 196 | /// `always` wins over `pull_requests` when both name them. | |
| 197 | pub fn bypass(actors: &[BypassActor], who: &ActorFacts, workspace: &str) -> Option<BypassMode> { | |
| 198 | let modes: Vec<BypassMode> = actors.iter().filter(|entry| names(entry, who, workspace)).map(|entry| entry.mode).collect(); | |
| 199 | if modes.contains(&BypassMode::Always) { | |
| 200 | Some(BypassMode::Always) | |
| 201 | } else { | |
| 202 | modes.first().copied() | |
| 203 | } | |
| 204 | } | |
| 205 | ||
| 206 | /// Whether any bypass list names a team: only then are the actor's teams | |
| 207 | /// worth looking up. | |
| 208 | pub fn names_teams(rulesets: &[Ruleset]) -> bool { | |
| 209 | rulesets | |
| 210 | .iter() | |
| 211 | .any(|ruleset| ruleset.spec.bypass_actors.iter().any(|entry| entry.kind == ActorKind::Team)) | |
| 212 | } | |
| 213 | ||
| 214 | /// The rulesets that hold in `repo` for any of `refs` (full refs), as a | |
| 215 | /// service applies them, with how the actor may bypass each. | |
| 216 | pub fn applicable(rulesets: &[Ruleset], repo: RepoFacts<'_>, refs: &[String], who: Option<&ActorFacts>, workspace: &str) -> Vec<Applicable> { | |
| 217 | rulesets | |
| 218 | .iter() | |
| 219 | .filter(|ruleset| holds_in(ruleset, repo)) | |
| 220 | .filter(|ruleset| { | |
| 221 | refs.iter().any(|git_ref| match Target::of_ref(git_ref) { | |
| 222 | Some((target, name)) => { | |
| 223 | target == ruleset.spec.target | |
| 224 | && ref_matches(&ruleset.spec.conditions.ref_name, target, name, repo.default_branch) | |
| 225 | } | |
| 226 | None => false, | |
| 227 | }) | |
| 228 | }) | |
| 229 | .map(|ruleset| Applicable { | |
| 230 | id: ruleset.id.clone(), | |
| 231 | name: ruleset.spec.name.clone(), | |
| 232 | level: ruleset.level, | |
| 233 | enforcement: ruleset.spec.enforcement, | |
| 234 | target: ruleset.spec.target, | |
| 235 | conditions: ruleset.spec.conditions.ref_name.clone(), | |
| 236 | rules: ruleset.spec.rules.clone(), | |
| 237 | bypass: who.and_then(|who| bypass(&ruleset.spec.bypass_actors, who, workspace)), | |
| 238 | }) | |
| 239 | .collect() | |
| 240 | } | |
| 241 | ||
| 242 | /// Whether an applicable ruleset holds for a full ref. | |
| 243 | pub fn applies_to_ref(ruleset: &Applicable, git_ref: &str, default_branch: &str) -> bool { | |
| 244 | match Target::of_ref(git_ref) { | |
| 245 | Some((target, name)) => target == ruleset.target && ref_matches(&ruleset.conditions, target, name, default_branch), | |
| 246 | None => false, | |
| 247 | } | |
| 248 | } | |
| 249 | ||
| 250 | /// Every rule that holds for one branch or tag: active rulesets' first, | |
| 251 | /// then those being evaluated, each with where it comes from. | |
| 252 | pub fn effective(rulesets: &[Ruleset], repo: RepoFacts<'_>, target: Target, name: &str) -> EffectiveRules { | |
| 253 | let mut holding: Vec<&Ruleset> = rulesets | |
| 254 | .iter() | |
| 255 | .filter(|ruleset| holds_in(ruleset, repo)) | |
| 256 | .filter(|ruleset| ruleset.spec.target == target) | |
| 257 | .filter(|ruleset| ref_matches(&ruleset.spec.conditions.ref_name, target, name, repo.default_branch)) | |
| 258 | .collect(); | |
| 259 | // Active before evaluate; workspace before repository; then by name. | |
| 260 | holding.sort_by_key(|ruleset| { | |
| 261 | ( | |
| 262 | ruleset.spec.enforcement != Enforcement::Active, | |
| 263 | ruleset.level != Level::Workspace, | |
| 264 | ruleset.spec.name.to_lowercase(), | |
| 265 | ) | |
| 266 | }); | |
| 267 | EffectiveRules { | |
| 268 | name: name.to_owned(), | |
| 269 | target, | |
| 270 | default_branch: target == Target::Branch && name == repo.default_branch, | |
| 271 | rules: holding | |
| 272 | .iter() | |
| 273 | .flat_map(|ruleset| { | |
| 274 | ruleset.spec.rules.iter().map(|entry| EffectiveRule { | |
| 275 | entry: entry.clone(), | |
| 276 | ruleset_id: ruleset.id.clone(), | |
| 277 | ruleset_name: ruleset.spec.name.clone(), | |
| 278 | level: ruleset.level, | |
| 279 | enforcement: ruleset.spec.enforcement, | |
| 280 | }) | |
| 281 | }) | |
| 282 | .collect(), | |
| 283 | rulesets: holding | |
| 284 | .iter() | |
| 285 | .map(|ruleset| RulesetSummary { | |
| 286 | id: ruleset.id.clone(), | |
| 287 | name: ruleset.spec.name.clone(), | |
| 288 | level: ruleset.level, | |
| 289 | enforcement: ruleset.spec.enforcement, | |
| 290 | bypass_actors: ruleset.spec.bypass_actors.clone(), | |
| 291 | }) | |
| 292 | .collect(), | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | #[cfg(test)] | |
| 297 | mod tests { | |
| 298 | use super::*; | |
| 299 | use g1t_contracts::rules::{BypassMode, Conditions, NoParameters, Rule, RuleEntry, RulesetSpec}; | |
| 300 | ||
| 301 | pub(crate) fn ruleset(id: &str, level: Level, include: &[&str], exclude: &[&str], rules: Vec<Rule>) -> Ruleset { | |
| 302 | Ruleset { | |
| 303 | id: id.into(), | |
| 304 | level, | |
| 305 | workspace: "acme".into(), | |
| 306 | repo_id: (level == Level::Repository).then(|| "rep_1".to_owned()), | |
| 307 | repository: None, | |
| 308 | spec: RulesetSpec { | |
| 309 | name: id.into(), | |
| 310 | conditions: Conditions { | |
| 311 | ref_name: RefCondition { | |
| 312 | include: include.iter().map(|p| (*p).to_owned()).collect(), | |
| 313 | exclude: exclude.iter().map(|p| (*p).to_owned()).collect(), | |
| 314 | }, | |
| 315 | repository: None, | |
| 316 | }, | |
| 317 | rules: rules.into_iter().map(RuleEntry::everyone).collect(), | |
| 318 | ..RulesetSpec::default() | |
| 319 | }, | |
| 320 | source: None, | |
| 321 | created_by: "ada".into(), | |
| 322 | created_at: String::new(), | |
| 323 | updated_by: "ada".into(), | |
| 324 | updated_at: String::new(), | |
| 325 | } | |
| 326 | } | |
| 327 | ||
| 328 | fn repo<'a>(topics: &'a [String]) -> RepoFacts<'a> { | |
| 329 | RepoFacts { id: "rep_1", name: "web", private: true, topics, default_branch: "main" } | |
| 330 | } | |
| 331 | ||
| 332 | #[test] | |
| 333 | fn names_match_patterns_the_default_branch_and_all() { | |
| 334 | let condition = |include: &[&str], exclude: &[&str]| RefCondition { | |
| 335 | include: include.iter().map(|p| (*p).to_owned()).collect(), | |
| 336 | exclude: exclude.iter().map(|p| (*p).to_owned()).collect(), | |
| 337 | }; | |
| 338 | assert!(ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Branch, "main", "main")); | |
| 339 | assert!(!ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Branch, "dev", "main")); | |
| 340 | assert!(!ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Tag, "main", "main")); | |
| 341 | assert!(ref_matches(&condition(&["~ALL"], &["dependabot/**"]), Target::Branch, "feature/x", "main")); | |
| 342 | assert!(!ref_matches(&condition(&["~ALL"], &["g1t-queue/**"]), Target::Branch, "g1t-queue/a", "main")); | |
| 343 | assert!(ref_matches(&condition(&["refs/heads/release/*"], &[]), Target::Branch, "release/2", "main")); | |
| 344 | assert!(ref_matches(&condition(&["v*"], &[]), Target::Tag, "v1.0.0", "main")); | |
| 345 | assert!(!ref_matches(&condition(&[], &[]), Target::Branch, "main", "main"), "an empty include selects nothing"); | |
| 346 | } | |
| 347 | ||
| 348 | #[test] | |
| 349 | fn workspace_rulesets_select_repositories_by_name_visibility_and_topic() { | |
| 350 | let topics = vec!["payments".to_owned()]; | |
| 351 | let mut condition = RepositoryCondition::default(); | |
| 352 | assert!(repo_matches(&condition, repo(&topics))); | |
| 353 | condition.include = vec!["api-*".into()]; | |
| 354 | assert!(!repo_matches(&condition, repo(&topics))); | |
| 355 | condition.include = vec!["W*".into()]; | |
| 356 | assert!(repo_matches(&condition, repo(&topics)), "names ignore case"); | |
| 357 | condition.exclude = vec!["web".into()]; | |
| 358 | assert!(!repo_matches(&condition, repo(&topics))); | |
| 359 | condition.exclude.clear(); | |
| 360 | condition.visibility = VisibilityCondition::Public; | |
| 361 | assert!(!repo_matches(&condition, repo(&topics))); | |
| 362 | condition.visibility = VisibilityCondition::Private; | |
| 363 | condition.topics = vec!["Payments".into()]; | |
| 364 | assert!(repo_matches(&condition, repo(&topics))); | |
| 365 | condition.topics = vec!["docs".into()]; | |
| 366 | assert!(!repo_matches(&condition, repo(&topics))); | |
| 367 | } | |
| 368 | ||
| 369 | #[test] | |
| 370 | fn a_repository_ruleset_holds_only_in_its_repository_and_disabled_ones_nowhere() { | |
| 371 | let topics = Vec::new(); | |
| 372 | let own = ruleset("a", Level::Repository, &["~ALL"], &[], vec![]); | |
| 373 | assert!(holds_in(&own, repo(&topics))); | |
| 374 | let other = Ruleset { repo_id: Some("rep_2".into()), ..own.clone() }; | |
| 375 | assert!(!holds_in(&other, repo(&topics))); | |
| 376 | let mut off = own.clone(); | |
| 377 | off.spec.enforcement = Enforcement::Disabled; | |
| 378 | assert!(!holds_in(&off, repo(&topics))); | |
| 379 | } | |
| 380 | ||
| 381 | fn person(role: RepoRole) -> ActorFacts { | |
| 382 | ActorFacts { username: "ada".into(), kind: Who::Person, role: Some(role), ..ActorFacts::default() } | |
| 383 | } | |
| 384 | ||
| 385 | fn entry(kind: ActorKind, value: &str, mode: BypassMode) -> BypassActor { | |
| 386 | BypassActor { kind, value: value.into(), mode } | |
| 387 | } | |
| 388 | ||
| 389 | #[test] | |
| 390 | fn nobody_bypasses_by_default() { | |
| 391 | assert_eq!(bypass(&[], &person(RepoRole::Admin), "acme"), None); | |
| 392 | let g1t = ActorFacts { kind: Who::G1t, username: "g1t".into(), ..ActorFacts::default() }; | |
| 393 | assert_eq!(bypass(&[], &g1t, "acme"), None); | |
| 394 | } | |
| 395 | ||
| 396 | #[test] | |
| 397 | fn roles_people_and_teams_bypass_as_listed() { | |
| 398 | let list = [entry(ActorKind::Role, "maintain", BypassMode::PullRequests)]; | |
| 399 | assert_eq!(bypass(&list, &person(RepoRole::Admin), "acme"), Some(BypassMode::PullRequests)); | |
| 400 | assert_eq!(bypass(&list, &person(RepoRole::Write), "acme"), None); | |
| 401 | let both = [ | |
| 402 | entry(ActorKind::Role, "write", BypassMode::PullRequests), | |
| 403 | entry(ActorKind::User, "@Ada", BypassMode::Always), | |
| 404 | ]; | |
| 405 | assert_eq!(bypass(&both, &person(RepoRole::Write), "acme"), Some(BypassMode::Always)); | |
| 406 | let team = [entry(ActorKind::Team, "release", BypassMode::Always)]; | |
| 407 | let mut ada = person(RepoRole::Read); | |
| 408 | assert_eq!(bypass(&team, &ada, "acme"), None); | |
| 409 | ada.teams.push("acme/release".into()); | |
| 410 | assert_eq!(bypass(&team, &ada, "acme"), Some(BypassMode::Always)); | |
| 411 | let owners = [entry(ActorKind::Role, "owner", BypassMode::Always)]; | |
| 412 | assert_eq!(bypass(&owners, &ada, "acme"), None); | |
| 413 | ada.owner = true; | |
| 414 | assert_eq!(bypass(&owners, &ada, "acme"), Some(BypassMode::Always)); | |
| 415 | } | |
| 416 | ||
| 417 | #[test] | |
| 418 | fn agents_bypass_only_when_g1t_or_their_token_is_listed() { | |
| 419 | let agent = ActorFacts { | |
| 420 | username: "g1t".into(), | |
| 421 | kind: Who::Agent, | |
| 422 | role: Some(RepoRole::Admin), | |
| 423 | owner: true, | |
| 424 | token_id: Some("tok_1".into()), | |
| 425 | ..ActorFacts::default() | |
| 426 | }; | |
| 427 | let admins = [entry(ActorKind::Role, "admin", BypassMode::Always), entry(ActorKind::Role, "owner", BypassMode::Always)]; | |
| 428 | assert_eq!(bypass(&admins, &agent, "acme"), None, "an agent does not take its person's role"); | |
| 429 | assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always)); | |
| 430 | assert_eq!(bypass(&[entry(ActorKind::Token, "tok_1", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always)); | |
| 431 | assert_eq!(bypass(&[entry(ActorKind::Token, "tok_2", BypassMode::Always)], &agent, "acme"), None); | |
| 432 | let system = ActorFacts { kind: Who::G1t, ..ActorFacts::default() }; | |
| 433 | assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::PullRequests)], &system, "acme"), Some(BypassMode::PullRequests)); | |
| 434 | let token = ActorFacts { kind: Who::Token, ..ActorFacts::default() }; | |
| 435 | assert_eq!(bypass(&[entry(ActorKind::Token, "workspace", BypassMode::Always)], &token, "acme"), Some(BypassMode::Always)); | |
| 436 | } | |
| 437 | ||
| 438 | #[test] | |
| 439 | fn effective_rules_stack_every_ruleset_that_holds() { | |
| 440 | let topics = Vec::new(); | |
| 441 | let mut evaluate = ruleset("b-dry", Level::Repository, &["main"], &[], vec![Rule::RequiredLinearHistory(NoParameters {})]); | |
| 442 | evaluate.spec.enforcement = Enforcement::Evaluate; | |
| 443 | let rulesets = vec![ | |
| 444 | evaluate, | |
| 445 | ruleset("a-main", Level::Repository, &["~DEFAULT_BRANCH"], &[], vec![Rule::Deletion(NoParameters {}), Rule::NonFastForward(NoParameters {})]), | |
| 446 | ruleset("org", Level::Workspace, &["~ALL"], &[], vec![Rule::Deletion(NoParameters {})]), | |
| 447 | ruleset("release", Level::Repository, &["release/*"], &[], vec![Rule::Creation(NoParameters {})]), | |
| 448 | ]; | |
| 449 | let main = effective(&rulesets, repo(&topics), Target::Branch, "main"); | |
| 450 | assert!(main.default_branch); | |
| 451 | let from: Vec<(&str, &str)> = main.rules.iter().map(|rule| (rule.ruleset_id.as_str(), rule.entry.rule.kind())).collect(); | |
| 452 | assert_eq!( | |
| 453 | from, | |
| 454 | vec![("org", "deletion"), ("a-main", "deletion"), ("a-main", "non_fast_forward"), ("b-dry", "required_linear_history")] | |
| 455 | ); | |
| 456 | assert_eq!(main.rulesets.len(), 3); | |
| 457 | let release = effective(&rulesets, repo(&topics), Target::Branch, "release/1"); | |
| 458 | assert_eq!(release.rules.iter().map(|rule| rule.entry.rule.kind()).collect::<Vec<_>>(), vec!["deletion", "creation"]); | |
| 459 | assert!(effective(&rulesets, repo(&topics), Target::Tag, "main").rules.is_empty()); | |
| 460 | } | |
| 461 | ||
| 462 | #[test] | |
| 463 | fn applicable_rulesets_carry_the_actors_bypass() { | |
| 464 | let topics = Vec::new(); | |
| 465 | let mut guarded = ruleset("a", Level::Repository, &["~DEFAULT_BRANCH"], &[], vec![Rule::Update(NoParameters {})]); | |
| 466 | guarded.spec.bypass_actors = vec![entry(ActorKind::Role, "admin", BypassMode::Always)]; | |
| 467 | let rulesets = vec![guarded, ruleset("b", Level::Repository, &["feature/*"], &[], vec![])]; | |
| 468 | let found = applicable(&rulesets, repo(&topics), &["refs/heads/main".into()], Some(&person(RepoRole::Admin)), "acme"); | |
| 469 | assert_eq!(found.len(), 1); | |
| 470 | assert_eq!(found[0].bypass, Some(BypassMode::Always)); | |
| 471 | assert!(applies_to_ref(&found[0], "refs/heads/main", "main")); | |
| 472 | assert!(!applies_to_ref(&found[0], "refs/tags/main", "main")); | |
| 473 | let none = applicable(&rulesets, repo(&topics), &["refs/heads/main".into()], Some(&person(RepoRole::Write)), "acme"); | |
| 474 | assert_eq!(none[0].bypass, None); | |
| 475 | } | |
| 476 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.