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