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 { | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 178 | // g1t on its own, as `System`: the merge queue, security updates, |
| 179 | // a merge its settings make. An agent acting through a token is | |
| 180 | // named only by that token. | |
| 181 | ActorKind::G1t => who.kind == Who::G1t, | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 182 | ActorKind::Token => { |
| 183 | (value.eq_ignore_ascii_case("workspace") && who.kind == Who::Token) | |
| 184 | || who.token_id.as_deref().is_some_and(|id| !value.is_empty() && id == value) | |
| 185 | } | |
| 186 | _ if who.kind != Who::Person => false, | |
| 187 | ActorKind::User => !value.is_empty() && who.username.eq_ignore_ascii_case(value.trim_start_matches('@')), | |
| 188 | ActorKind::Team => !value.is_empty() && who.teams.contains(&team_key(value, workspace)), | |
| 189 | ActorKind::Role => match value.to_ascii_lowercase().as_str() { | |
| 190 | "owner" => who.owner, | |
| 191 | role => RepoRole::parse(role).is_some_and(|wanted| who.role.is_some_and(|has| has >= wanted)), | |
| 192 | }, | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | /// How the actor may bypass a ruleset with these bypass actors, if at all. | |
| 197 | /// `always` wins over `pull_requests` when both name them. | |
| 198 | pub fn bypass(actors: &[BypassActor], who: &ActorFacts, workspace: &str) -> Option<BypassMode> { | |
| 199 | let modes: Vec<BypassMode> = actors.iter().filter(|entry| names(entry, who, workspace)).map(|entry| entry.mode).collect(); | |
| 200 | if modes.contains(&BypassMode::Always) { | |
| 201 | Some(BypassMode::Always) | |
| 202 | } else { | |
| 203 | modes.first().copied() | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | /// Whether any bypass list names a team: only then are the actor's teams | |
| 208 | /// worth looking up. | |
| 209 | pub fn names_teams(rulesets: &[Ruleset]) -> bool { | |
| 210 | rulesets | |
| 211 | .iter() | |
| 212 | .any(|ruleset| ruleset.spec.bypass_actors.iter().any(|entry| entry.kind == ActorKind::Team)) | |
| 213 | } | |
| 214 | ||
| 215 | /// The rulesets that hold in `repo` for any of `refs` (full refs), as a | |
| 216 | /// service applies them, with how the actor may bypass each. | |
| 217 | pub fn applicable(rulesets: &[Ruleset], repo: RepoFacts<'_>, refs: &[String], who: Option<&ActorFacts>, workspace: &str) -> Vec<Applicable> { | |
| 218 | rulesets | |
| 219 | .iter() | |
| 220 | .filter(|ruleset| holds_in(ruleset, repo)) | |
| 221 | .filter(|ruleset| { | |
| 222 | refs.iter().any(|git_ref| match Target::of_ref(git_ref) { | |
| 223 | Some((target, name)) => { | |
| 224 | target == ruleset.spec.target | |
| 225 | && ref_matches(&ruleset.spec.conditions.ref_name, target, name, repo.default_branch) | |
| 226 | } | |
| 227 | None => false, | |
| 228 | }) | |
| 229 | }) | |
| 230 | .map(|ruleset| Applicable { | |
| 231 | id: ruleset.id.clone(), | |
| 232 | name: ruleset.spec.name.clone(), | |
| 233 | level: ruleset.level, | |
| 234 | enforcement: ruleset.spec.enforcement, | |
| 235 | target: ruleset.spec.target, | |
| 236 | conditions: ruleset.spec.conditions.ref_name.clone(), | |
| 237 | rules: ruleset.spec.rules.clone(), | |
| 238 | bypass: who.and_then(|who| bypass(&ruleset.spec.bypass_actors, who, workspace)), | |
| 239 | }) | |
| 240 | .collect() | |
| 241 | } | |
| 242 | ||
| 243 | /// Whether an applicable ruleset holds for a full ref. | |
| 244 | pub fn applies_to_ref(ruleset: &Applicable, git_ref: &str, default_branch: &str) -> bool { | |
| 245 | match Target::of_ref(git_ref) { | |
| 246 | Some((target, name)) => target == ruleset.target && ref_matches(&ruleset.conditions, target, name, default_branch), | |
| 247 | None => false, | |
| 248 | } | |
| 249 | } | |
| 250 | ||
| 251 | /// Every rule that holds for one branch or tag: active rulesets' first, | |
| 252 | /// then those being evaluated, each with where it comes from. | |
| 253 | pub fn effective(rulesets: &[Ruleset], repo: RepoFacts<'_>, target: Target, name: &str) -> EffectiveRules { | |
| 254 | let mut holding: Vec<&Ruleset> = rulesets | |
| 255 | .iter() | |
| 256 | .filter(|ruleset| holds_in(ruleset, repo)) | |
| 257 | .filter(|ruleset| ruleset.spec.target == target) | |
| 258 | .filter(|ruleset| ref_matches(&ruleset.spec.conditions.ref_name, target, name, repo.default_branch)) | |
| 259 | .collect(); | |
| 260 | // Active before evaluate; workspace before repository; then by name. | |
| 261 | holding.sort_by_key(|ruleset| { | |
| 262 | ( | |
| 263 | ruleset.spec.enforcement != Enforcement::Active, | |
| 264 | ruleset.level != Level::Workspace, | |
| 265 | ruleset.spec.name.to_lowercase(), | |
| 266 | ) | |
| 267 | }); | |
| 268 | EffectiveRules { | |
| 269 | name: name.to_owned(), | |
| 270 | target, | |
| 271 | default_branch: target == Target::Branch && name == repo.default_branch, | |
| 272 | rules: holding | |
| 273 | .iter() | |
| 274 | .flat_map(|ruleset| { | |
| 275 | ruleset.spec.rules.iter().map(|entry| EffectiveRule { | |
| 276 | entry: entry.clone(), | |
| 277 | ruleset_id: ruleset.id.clone(), | |
| 278 | ruleset_name: ruleset.spec.name.clone(), | |
| 279 | level: ruleset.level, | |
| 280 | enforcement: ruleset.spec.enforcement, | |
| 281 | }) | |
| 282 | }) | |
| 283 | .collect(), | |
| 284 | rulesets: holding | |
| 285 | .iter() | |
| 286 | .map(|ruleset| RulesetSummary { | |
| 287 | id: ruleset.id.clone(), | |
| 288 | name: ruleset.spec.name.clone(), | |
| 289 | level: ruleset.level, | |
| 290 | enforcement: ruleset.spec.enforcement, | |
| 291 | bypass_actors: ruleset.spec.bypass_actors.clone(), | |
| 292 | }) | |
| 293 | .collect(), | |
| 294 | } | |
| 295 | } | |
| 296 | ||
| 297 | #[cfg(test)] | |
| 298 | mod tests { | |
| 299 | use super::*; | |
| 300 | use g1t_contracts::rules::{BypassMode, Conditions, NoParameters, Rule, RuleEntry, RulesetSpec}; | |
| 301 | ||
| 302 | pub(crate) fn ruleset(id: &str, level: Level, include: &[&str], exclude: &[&str], rules: Vec<Rule>) -> Ruleset { | |
| 303 | Ruleset { | |
| 304 | id: id.into(), | |
| 305 | level, | |
| 306 | workspace: "acme".into(), | |
| 307 | repo_id: (level == Level::Repository).then(|| "rep_1".to_owned()), | |
| 308 | repository: None, | |
| 309 | spec: RulesetSpec { | |
| 310 | name: id.into(), | |
| 311 | conditions: Conditions { | |
| 312 | ref_name: RefCondition { | |
| 313 | include: include.iter().map(|p| (*p).to_owned()).collect(), | |
| 314 | exclude: exclude.iter().map(|p| (*p).to_owned()).collect(), | |
| 315 | }, | |
| 316 | repository: None, | |
| 317 | }, | |
| 318 | rules: rules.into_iter().map(RuleEntry::everyone).collect(), | |
| 319 | ..RulesetSpec::default() | |
| 320 | }, | |
| 321 | source: None, | |
| 322 | created_by: "ada".into(), | |
| 323 | created_at: String::new(), | |
| 324 | updated_by: "ada".into(), | |
| 325 | updated_at: String::new(), | |
| 326 | } | |
| 327 | } | |
| 328 | ||
| 329 | fn repo<'a>(topics: &'a [String]) -> RepoFacts<'a> { | |
| 330 | RepoFacts { id: "rep_1", name: "web", private: true, topics, default_branch: "main" } | |
| 331 | } | |
| 332 | ||
| 333 | #[test] | |
| 334 | fn names_match_patterns_the_default_branch_and_all() { | |
| 335 | let condition = |include: &[&str], exclude: &[&str]| RefCondition { | |
| 336 | include: include.iter().map(|p| (*p).to_owned()).collect(), | |
| 337 | exclude: exclude.iter().map(|p| (*p).to_owned()).collect(), | |
| 338 | }; | |
| 339 | assert!(ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Branch, "main", "main")); | |
| 340 | assert!(!ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Branch, "dev", "main")); | |
| 341 | assert!(!ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Tag, "main", "main")); | |
| 342 | assert!(ref_matches(&condition(&["~ALL"], &["dependabot/**"]), Target::Branch, "feature/x", "main")); | |
| 343 | assert!(!ref_matches(&condition(&["~ALL"], &["g1t-queue/**"]), Target::Branch, "g1t-queue/a", "main")); | |
| 344 | assert!(ref_matches(&condition(&["refs/heads/release/*"], &[]), Target::Branch, "release/2", "main")); | |
| 345 | assert!(ref_matches(&condition(&["v*"], &[]), Target::Tag, "v1.0.0", "main")); | |
| 346 | assert!(!ref_matches(&condition(&[], &[]), Target::Branch, "main", "main"), "an empty include selects nothing"); | |
| 347 | } | |
| 348 | ||
| 349 | #[test] | |
| 350 | fn workspace_rulesets_select_repositories_by_name_visibility_and_topic() { | |
| 351 | let topics = vec!["payments".to_owned()]; | |
| 352 | let mut condition = RepositoryCondition::default(); | |
| 353 | assert!(repo_matches(&condition, repo(&topics))); | |
| 354 | condition.include = vec!["api-*".into()]; | |
| 355 | assert!(!repo_matches(&condition, repo(&topics))); | |
| 356 | condition.include = vec!["W*".into()]; | |
| 357 | assert!(repo_matches(&condition, repo(&topics)), "names ignore case"); | |
| 358 | condition.exclude = vec!["web".into()]; | |
| 359 | assert!(!repo_matches(&condition, repo(&topics))); | |
| 360 | condition.exclude.clear(); | |
| 361 | condition.visibility = VisibilityCondition::Public; | |
| 362 | assert!(!repo_matches(&condition, repo(&topics))); | |
| 363 | condition.visibility = VisibilityCondition::Private; | |
| 364 | condition.topics = vec!["Payments".into()]; | |
| 365 | assert!(repo_matches(&condition, repo(&topics))); | |
| 366 | condition.topics = vec!["docs".into()]; | |
| 367 | assert!(!repo_matches(&condition, repo(&topics))); | |
| 368 | } | |
| 369 | ||
| 370 | #[test] | |
| 371 | fn a_repository_ruleset_holds_only_in_its_repository_and_disabled_ones_nowhere() { | |
| 372 | let topics = Vec::new(); | |
| 373 | let own = ruleset("a", Level::Repository, &["~ALL"], &[], vec![]); | |
| 374 | assert!(holds_in(&own, repo(&topics))); | |
| 375 | let other = Ruleset { repo_id: Some("rep_2".into()), ..own.clone() }; | |
| 376 | assert!(!holds_in(&other, repo(&topics))); | |
| 377 | let mut off = own.clone(); | |
| 378 | off.spec.enforcement = Enforcement::Disabled; | |
| 379 | assert!(!holds_in(&off, repo(&topics))); | |
| 380 | } | |
| 381 | ||
| 382 | fn person(role: RepoRole) -> ActorFacts { | |
| 383 | ActorFacts { username: "ada".into(), kind: Who::Person, role: Some(role), ..ActorFacts::default() } | |
| 384 | } | |
| 385 | ||
| 386 | fn entry(kind: ActorKind, value: &str, mode: BypassMode) -> BypassActor { | |
| 387 | BypassActor { kind, value: value.into(), mode } | |
| 388 | } | |
| 389 | ||
| 390 | #[test] | |
| 391 | fn nobody_bypasses_by_default() { | |
| 392 | assert_eq!(bypass(&[], &person(RepoRole::Admin), "acme"), None); | |
| 393 | let g1t = ActorFacts { kind: Who::G1t, username: "g1t".into(), ..ActorFacts::default() }; | |
| 394 | assert_eq!(bypass(&[], &g1t, "acme"), None); | |
| 395 | } | |
| 396 | ||
| 397 | #[test] | |
| 398 | fn roles_people_and_teams_bypass_as_listed() { | |
| 399 | let list = [entry(ActorKind::Role, "maintain", BypassMode::PullRequests)]; | |
| 400 | assert_eq!(bypass(&list, &person(RepoRole::Admin), "acme"), Some(BypassMode::PullRequests)); | |
| 401 | assert_eq!(bypass(&list, &person(RepoRole::Write), "acme"), None); | |
| 402 | let both = [ | |
| 403 | entry(ActorKind::Role, "write", BypassMode::PullRequests), | |
| 404 | entry(ActorKind::User, "@Ada", BypassMode::Always), | |
| 405 | ]; | |
| 406 | assert_eq!(bypass(&both, &person(RepoRole::Write), "acme"), Some(BypassMode::Always)); | |
| 407 | let team = [entry(ActorKind::Team, "release", BypassMode::Always)]; | |
| 408 | let mut ada = person(RepoRole::Read); | |
| 409 | assert_eq!(bypass(&team, &ada, "acme"), None); | |
| 410 | ada.teams.push("acme/release".into()); | |
| 411 | assert_eq!(bypass(&team, &ada, "acme"), Some(BypassMode::Always)); | |
| 412 | let owners = [entry(ActorKind::Role, "owner", BypassMode::Always)]; | |
| 413 | assert_eq!(bypass(&owners, &ada, "acme"), None); | |
| 414 | ada.owner = true; | |
| 415 | assert_eq!(bypass(&owners, &ada, "acme"), Some(BypassMode::Always)); | |
| 416 | } | |
| 417 | ||
| 418 | #[test] | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 419 | fn agents_bypass_only_when_their_token_is_listed() { |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 420 | let agent = ActorFacts { |
| 421 | username: "g1t".into(), | |
| 422 | kind: Who::Agent, | |
| 423 | role: Some(RepoRole::Admin), | |
| 424 | owner: true, | |
| 425 | token_id: Some("tok_1".into()), | |
| 426 | ..ActorFacts::default() | |
| 427 | }; | |
| 428 | let admins = [entry(ActorKind::Role, "admin", BypassMode::Always), entry(ActorKind::Role, "owner", BypassMode::Always)]; | |
| 429 | assert_eq!(bypass(&admins, &agent, "acme"), None, "an agent does not take its person's role"); | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 430 | assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::Always)], &agent, "acme"), None, "g1t names g1t itself, not agents"); |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 431 | assert_eq!(bypass(&[entry(ActorKind::Token, "tok_1", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always)); |
| 432 | assert_eq!(bypass(&[entry(ActorKind::Token, "tok_2", BypassMode::Always)], &agent, "acme"), None); | |
| 433 | let system = ActorFacts { kind: Who::G1t, ..ActorFacts::default() }; | |
| 434 | assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::PullRequests)], &system, "acme"), Some(BypassMode::PullRequests)); | |
| 435 | let token = ActorFacts { kind: Who::Token, ..ActorFacts::default() }; | |
| 436 | assert_eq!(bypass(&[entry(ActorKind::Token, "workspace", BypassMode::Always)], &token, "acme"), Some(BypassMode::Always)); | |
| 437 | } | |
| 438 | ||
| 439 | #[test] | |
| 440 | fn effective_rules_stack_every_ruleset_that_holds() { | |
| 441 | let topics = Vec::new(); | |
| 442 | let mut evaluate = ruleset("b-dry", Level::Repository, &["main"], &[], vec![Rule::RequiredLinearHistory(NoParameters {})]); | |
| 443 | evaluate.spec.enforcement = Enforcement::Evaluate; | |
| 444 | let rulesets = vec![ | |
| 445 | evaluate, | |
| 446 | ruleset("a-main", Level::Repository, &["~DEFAULT_BRANCH"], &[], vec![Rule::Deletion(NoParameters {}), Rule::NonFastForward(NoParameters {})]), | |
| 447 | ruleset("org", Level::Workspace, &["~ALL"], &[], vec![Rule::Deletion(NoParameters {})]), | |
| 448 | ruleset("release", Level::Repository, &["release/*"], &[], vec![Rule::Creation(NoParameters {})]), | |
| 449 | ]; | |
| 450 | let main = effective(&rulesets, repo(&topics), Target::Branch, "main"); | |
| 451 | assert!(main.default_branch); | |
| 452 | let from: Vec<(&str, &str)> = main.rules.iter().map(|rule| (rule.ruleset_id.as_str(), rule.entry.rule.kind())).collect(); | |
| 453 | assert_eq!( | |
| 454 | from, | |
| 455 | vec![("org", "deletion"), ("a-main", "deletion"), ("a-main", "non_fast_forward"), ("b-dry", "required_linear_history")] | |
| 456 | ); | |
| 457 | assert_eq!(main.rulesets.len(), 3); | |
| 458 | let release = effective(&rulesets, repo(&topics), Target::Branch, "release/1"); | |
| 459 | assert_eq!(release.rules.iter().map(|rule| rule.entry.rule.kind()).collect::<Vec<_>>(), vec!["deletion", "creation"]); | |
| 460 | assert!(effective(&rulesets, repo(&topics), Target::Tag, "main").rules.is_empty()); | |
| 461 | } | |
| 462 | ||
| 463 | #[test] | |
| 464 | fn applicable_rulesets_carry_the_actors_bypass() { | |
| 465 | let topics = Vec::new(); | |
| 466 | let mut guarded = ruleset("a", Level::Repository, &["~DEFAULT_BRANCH"], &[], vec![Rule::Update(NoParameters {})]); | |
| 467 | guarded.spec.bypass_actors = vec![entry(ActorKind::Role, "admin", BypassMode::Always)]; | |
| 468 | let rulesets = vec![guarded, ruleset("b", Level::Repository, &["feature/*"], &[], vec![])]; | |
| 469 | let found = applicable(&rulesets, repo(&topics), &["refs/heads/main".into()], Some(&person(RepoRole::Admin)), "acme"); | |
| 470 | assert_eq!(found.len(), 1); | |
| 471 | assert_eq!(found[0].bypass, Some(BypassMode::Always)); | |
| 472 | assert!(applies_to_ref(&found[0], "refs/heads/main", "main")); | |
| 473 | assert!(!applies_to_ref(&found[0], "refs/tags/main", "main")); | |
| 474 | let none = applicable(&rulesets, repo(&topics), &["refs/heads/main".into()], Some(&person(RepoRole::Write)), "acme"); | |
| 475 | assert_eq!(none[0].bypass, None); | |
| 476 | } | |
| 477 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.