| 1 | //! Judging a pull request's merge into a branch: approvals, checks, |
| 2 | //! deployments, the commits it lands, and the agent-first rules (how sure |
| 3 | //! g1t is of an agent's change, what it cost, who must look at sensitive |
| 4 | //! paths, and when merging is allowed at all). |
| 5 | //! |
| 6 | //! The merge button, the API, MCP, auto-merge, g1t's lifecycle and the |
| 7 | //! merge queue all ask this one question, so a pull request merges the |
| 8 | //! same way whoever merges it. |
| 9 | |
| 10 | use std::collections::HashMap; |
| 11 | |
| 12 | use g1t_contracts::rules::{ |
| 13 | Applicable, ConfidenceLevel, Enforcement, InspectedCommits, Integration, MergeMethod, MergeQueueRule, Rule, |
| 14 | StatusChecksRule, |
| 15 | }; |
| 16 | use g1t_contracts::work::{CommitStatus, RequiredState, Verdict, check_name, required_checks}; |
| 17 | |
| 18 | use crate::content::{self, Problem}; |
| 19 | use crate::glob; |
| 20 | use crate::outcome::Judged; |
| 21 | use crate::select::applies_to_ref; |
| 22 | use crate::window; |
| 23 | |
| 24 | /// One reviewer's latest verdict. |
| 25 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 26 | pub struct Review { |
| 27 | pub reviewer_id: String, |
| 28 | pub username: String, |
| 29 | pub verdict: Verdict, |
| 30 | /// RFC 3339. |
| 31 | pub at: String, |
| 32 | /// g1t's reviewer agent. |
| 33 | pub agent: bool, |
| 34 | } |
| 35 | |
| 36 | /// Everything a merge is judged on. |
| 37 | #[derive(Clone, Debug, Default)] |
| 38 | pub struct MergeFacts<'a> { |
| 39 | /// The branch it merges into, as a full ref. |
| 40 | pub git_ref: String, |
| 41 | /// Whether an agent made the change. |
| 42 | pub agent_change: bool, |
| 43 | /// Who answers for it (whoever asked g1t for it, or its author). |
| 44 | pub owner_id: &'a str, |
| 45 | /// Each reviewer's latest verdict. |
| 46 | pub reviews: &'a [Review], |
| 47 | /// When its head last moved, and by whom (a user id), if known. |
| 48 | pub head_pushed_at: Option<&'a str>, |
| 49 | pub head_pushed_by: Option<&'a str>, |
| 50 | /// What its code owners have still to approve, when that was asked. |
| 51 | pub code_owners_missing: Option<&'a str>, |
| 52 | /// The statuses on its head. |
| 53 | pub statuses: &'a [CommitStatus], |
| 54 | /// Whether the branch it merges into has moved without it. |
| 55 | pub behind: bool, |
| 56 | /// The files it changes. |
| 57 | pub files: &'a [String], |
| 58 | /// Its commits, read, when a rule about commits holds. |
| 59 | pub commits: Option<&'a InspectedCommits>, |
| 60 | /// How sure g1t is of an agent's change, once rated. |
| 61 | pub confidence: Option<ConfidenceLevel>, |
| 62 | /// What agents have spent on it, in US dollars. |
| 63 | pub spent_usd: f64, |
| 64 | /// Milliseconds since the epoch. |
| 65 | pub now_ms: u64, |
| 66 | pub method: Option<MergeMethod>, |
| 67 | /// The people of each team a rule names, by `workspace/slug`, |
| 68 | /// usernames lowercase. |
| 69 | pub team_members: Option<&'a HashMap<String, Vec<String>>>, |
| 70 | /// The merger asked to merge past required checks. |
| 71 | pub ignore_checks: bool, |
| 72 | } |
| 73 | |
| 74 | /// Where a status came from: as recorded, or from its name. |
| 75 | pub fn integration_of(status: &CommitStatus) -> Integration { |
| 76 | if let Some(found) = status.source.as_deref().and_then(Integration::parse) { |
| 77 | return found; |
| 78 | } |
| 79 | let context = status.context.as_str(); |
| 80 | if context.starts_with("g1t / deploy") { |
| 81 | Integration::Deployments |
| 82 | } else if matches!(context, "Code scanning" | "Dependency review") { |
| 83 | Integration::Security |
| 84 | } else if context.starts_with("g1t / ") || context == "Code owners" { |
| 85 | Integration::G1t |
| 86 | } else { |
| 87 | Integration::Actions |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /// The statuses that may meet `rule`'s checks: a check pinned to an |
| 92 | /// integration is met only by statuses that integration reported. |
| 93 | pub fn statuses_for(rule: &StatusChecksRule, statuses: &[CommitStatus]) -> Vec<CommitStatus> { |
| 94 | statuses |
| 95 | .iter() |
| 96 | .filter(|status| { |
| 97 | let name = check_name(&status.context).0; |
| 98 | rule.checks.iter().all(|check| { |
| 99 | !check.context.trim().eq_ignore_ascii_case(name) |
| 100 | || check.integration.is_none_or(|wanted| integration_of(status) == wanted) |
| 101 | }) |
| 102 | }) |
| 103 | .cloned() |
| 104 | .collect() |
| 105 | } |
| 106 | |
| 107 | /// Whether a status checks rule holds for a pull request changing `files`. |
| 108 | pub fn checks_hold(rule: &StatusChecksRule, files: &[String]) -> bool { |
| 109 | rule.paths.is_empty() || files.iter().any(|file| rule.paths.iter().any(|pattern| glob::path_matches(pattern, file))) |
| 110 | } |
| 111 | |
| 112 | fn plural(count: u32, one: &str, many: &str) -> String { |
| 113 | format!("{count} {}", if count == 1 { one } else { many }) |
| 114 | } |
| 115 | |
| 116 | /// The people (not g1t, not its owner) who approve it now; with `fresh`, |
| 117 | /// only approvals given since its head last moved. |
| 118 | fn people_approving<'a>(facts: &'a MergeFacts<'_>, fresh: bool) -> impl Iterator<Item = &'a Review> { |
| 119 | facts.reviews.iter().filter(move |review| { |
| 120 | review.verdict == Verdict::Approve |
| 121 | && !review.agent |
| 122 | && review.reviewer_id != facts.owner_id |
| 123 | && (!fresh || facts.head_pushed_at.is_none_or(|pushed| review.at.as_str() >= pushed)) |
| 124 | }) |
| 125 | } |
| 126 | |
| 127 | fn pull_request_problems(rule: &g1t_contracts::rules::PullRequestRule, facts: &MergeFacts<'_>) -> Vec<Problem> { |
| 128 | let mut problems = Vec::new(); |
| 129 | if rule.required_approvals > 0 { |
| 130 | let others = || facts.reviews.iter().filter(|review| review.reviewer_id != facts.owner_id); |
| 131 | if others().any(|review| review.verdict == Verdict::RequestChanges) { |
| 132 | problems.push(Problem::new( |
| 133 | "A reviewer has asked for changes.", |
| 134 | "Address the review and ask them to review again.", |
| 135 | )); |
| 136 | } else { |
| 137 | let counted = others() |
| 138 | .filter(|review| review.verdict == Verdict::Approve) |
| 139 | .filter(|review| rule.count_agent_approvals || !review.agent) |
| 140 | .filter(|review| { |
| 141 | !rule.dismiss_stale_reviews_on_push || facts.head_pushed_at.is_none_or(|pushed| review.at.as_str() >= pushed) |
| 142 | }) |
| 143 | .count() as u32; |
| 144 | if counted < rule.required_approvals { |
| 145 | let from = if rule.count_agent_approvals { "" } else { " from people" }; |
| 146 | let since = if rule.dismiss_stale_reviews_on_push { " since its latest push" } else { "" }; |
| 147 | problems.push(Problem::new( |
| 148 | format!( |
| 149 | "It needs {}{from}{since}; it has {counted}.", |
| 150 | plural(rule.required_approvals, "approving review", "approving reviews") |
| 151 | ), |
| 152 | "Ask for a review.", |
| 153 | )); |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | if rule.require_code_owner_review |
| 158 | && let Some(missing) = facts.code_owners_missing |
| 159 | { |
| 160 | problems.push(Problem::new(missing.to_owned(), "Ask its code owners to review it.")); |
| 161 | } |
| 162 | if rule.require_last_push_approval { |
| 163 | let pusher = facts.head_pushed_by.unwrap_or(facts.owner_id); |
| 164 | let approved = facts.reviews.iter().any(|review| { |
| 165 | review.verdict == Verdict::Approve |
| 166 | && review.reviewer_id != pusher |
| 167 | && (rule.count_agent_approvals || !review.agent) |
| 168 | && facts.head_pushed_at.is_none_or(|pushed| review.at.as_str() >= pushed) |
| 169 | }); |
| 170 | if !approved { |
| 171 | problems.push(Problem::new( |
| 172 | "Its latest push has not been approved by someone other than whoever pushed it.", |
| 173 | "Ask someone else to review the latest changes.", |
| 174 | )); |
| 175 | } |
| 176 | } |
| 177 | if let Some(method) = facts.method |
| 178 | && !rule.allowed_merge_methods.is_empty() |
| 179 | && !rule.allowed_merge_methods.contains(&method) |
| 180 | { |
| 181 | let allowed: Vec<&str> = rule.allowed_merge_methods.iter().map(|method| method.as_str()).collect(); |
| 182 | problems.push(Problem::new( |
| 183 | format!("This branch allows only {} merges, and this one would be a {} merge.", allowed.join(" or "), method.as_str()), |
| 184 | "Merge it another way allowed here.", |
| 185 | )); |
| 186 | } |
| 187 | problems |
| 188 | } |
| 189 | |
| 190 | fn checks_problems(rule: &StatusChecksRule, facts: &MergeFacts<'_>) -> Vec<Problem> { |
| 191 | if !checks_hold(rule, facts.files) { |
| 192 | return Vec::new(); |
| 193 | } |
| 194 | let mut problems = Vec::new(); |
| 195 | if !(facts.ignore_checks && rule.allow_bypass_on_merge) { |
| 196 | let names: Vec<String> = rule.checks.iter().map(|check| check.context.trim().to_owned()).collect(); |
| 197 | let statuses = statuses_for(rule, facts.statuses); |
| 198 | for check in required_checks(&names, &statuses) { |
| 199 | let pinned = rule |
| 200 | .checks |
| 201 | .iter() |
| 202 | .find(|wanted| wanted.context.trim().eq_ignore_ascii_case(&check.name)) |
| 203 | .and_then(|wanted| wanted.integration) |
| 204 | .map(|integration| format!(" from {}", integration.as_str())) |
| 205 | .unwrap_or_default(); |
| 206 | let message = match check.state { |
| 207 | RequiredState::Success => continue, |
| 208 | RequiredState::Failure => format!("The required check {}{pinned} failed.", check.name), |
| 209 | RequiredState::Pending => format!("The required check {}{pinned} has not finished.", check.name), |
| 210 | RequiredState::Expected => format!("The required check {}{pinned} has not reported on its latest commit.", check.name), |
| 211 | }; |
| 212 | let remedy = if rule.allow_bypass_on_merge { |
| 213 | "Wait or fix it, or bypass the required checks as you merge." |
| 214 | } else { |
| 215 | "Wait for it to pass, or push a fix." |
| 216 | }; |
| 217 | problems.push(Problem::new(message, remedy)); |
| 218 | } |
| 219 | } |
| 220 | if rule.strict && facts.behind { |
| 221 | problems.push(Problem::new( |
| 222 | "It is behind the branch it merges into, which requires pull requests to be up to date.", |
| 223 | "Catch up with the branch first; its required checks then run again.", |
| 224 | )); |
| 225 | } |
| 226 | problems |
| 227 | } |
| 228 | |
| 229 | /// The status a deployment to `environment` reports. |
| 230 | pub fn deployment_context(environment: &str) -> String { |
| 231 | let environment = environment.trim(); |
| 232 | if environment.is_empty() || environment.eq_ignore_ascii_case("preview") { |
| 233 | "g1t / deploy".to_owned() |
| 234 | } else { |
| 235 | format!("g1t / deploy ({environment})") |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | fn level_rank(level: ConfidenceLevel) -> u8 { |
| 240 | match level { |
| 241 | ConfidenceLevel::Low => 0, |
| 242 | ConfidenceLevel::Medium => 1, |
| 243 | ConfidenceLevel::High => 2, |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | /// The problems one rule finds in a merge. |
| 248 | fn rule_problems(rule: &Rule, facts: &MergeFacts<'_>) -> Vec<Problem> { |
| 249 | match rule { |
| 250 | // Restricting updates restricts merges too: a merge moves the branch. |
| 251 | Rule::Update(_) => { |
| 252 | let branch = facts.git_ref.strip_prefix("refs/heads/").unwrap_or(&facts.git_ref); |
| 253 | vec![Problem::new( |
| 254 | format!("Only people this ruleset lets bypass it may change {branch}, merges included."), |
| 255 | "Ask someone who may bypass this ruleset to merge it.", |
| 256 | )] |
| 257 | } |
| 258 | Rule::PullRequest(rule) => pull_request_problems(rule, facts), |
| 259 | Rule::RequiredStatusChecks(rule) => checks_problems(rule, facts), |
| 260 | Rule::RequiredDeployments(rule) => rule |
| 261 | .environments |
| 262 | .iter() |
| 263 | .filter(|environment| !environment.trim().is_empty()) |
| 264 | .filter_map(|environment| { |
| 265 | let context = deployment_context(environment); |
| 266 | let state = facts.statuses.iter().find(|status| status.context == context).map(|status| status.state.as_str()); |
| 267 | (state != Some("success")).then(|| { |
| 268 | Problem::new( |
| 269 | format!( |
| 270 | "It has not deployed to {} successfully{}.", |
| 271 | environment.trim(), |
| 272 | match state { |
| 273 | Some("pending") => " yet: the deployment is running", |
| 274 | Some(_) => ": the deployment failed", |
| 275 | None => "", |
| 276 | } |
| 277 | ), |
| 278 | "Wait for its deployment, or fix what made it fail and push.", |
| 279 | ) |
| 280 | }) |
| 281 | }) |
| 282 | .collect(), |
| 283 | rule if content::about_content(rule) => match facts.commits { |
| 284 | Some(inspected) => content::problems(rule, &inspected.commits, inspected.complete), |
| 285 | None => Vec::new(), |
| 286 | }, |
| 287 | Rule::ConfidenceThreshold(rule) if facts.agent_change => { |
| 288 | let below = facts.confidence.is_none_or(|level| level_rank(level) < level_rank(rule.minimum)); |
| 289 | let approvals = people_approving(facts, false).count() as u32; |
| 290 | if below && approvals < rule.required_approvals.max(1) { |
| 291 | let rated = match facts.confidence { |
| 292 | Some(level) => format!("g1t rates this agent's change {} confidence", level.as_str()), |
| 293 | None => "g1t has not rated this agent's change yet".to_owned(), |
| 294 | }; |
| 295 | vec![Problem::new( |
| 296 | format!( |
| 297 | "{rated}; below {} it needs {}.", |
| 298 | rule.minimum.as_str(), |
| 299 | plural(rule.required_approvals.max(1), "approval from a person", "approvals from people") |
| 300 | ), |
| 301 | "Review the change and approve it if it is right.", |
| 302 | )] |
| 303 | } else { |
| 304 | Vec::new() |
| 305 | } |
| 306 | } |
| 307 | Rule::CostCap(rule) if facts.spent_usd > rule.max_usd => { |
| 308 | if people_approving(facts, false).next().is_some() { |
| 309 | Vec::new() |
| 310 | } else { |
| 311 | vec![Problem::new( |
| 312 | format!( |
| 313 | "Agents have spent ${:.2} on this pull request, over its ${:.2} cap.", |
| 314 | facts.spent_usd, rule.max_usd |
| 315 | ), |
| 316 | "A person must approve it before it merges or its agent continues.", |
| 317 | )] |
| 318 | } |
| 319 | } |
| 320 | Rule::PathReview(rule) => { |
| 321 | let touched: Vec<&String> = facts |
| 322 | .files |
| 323 | .iter() |
| 324 | .filter(|file| rule.paths.iter().any(|pattern| glob::path_matches(pattern, file))) |
| 325 | .collect(); |
| 326 | if touched.is_empty() || rule.required_approvals == 0 { |
| 327 | return Vec::new(); |
| 328 | } |
| 329 | let members = rule.team.as_ref().map(|team| { |
| 330 | facts |
| 331 | .team_members |
| 332 | .and_then(|teams| teams.get(&team.trim().trim_start_matches('@').to_lowercase()).cloned()) |
| 333 | .unwrap_or_default() |
| 334 | }); |
| 335 | let approvals = people_approving(facts, true) |
| 336 | .filter(|review| members.as_ref().is_none_or(|members| members.contains(&review.username.to_lowercase()))) |
| 337 | .count() as u32; |
| 338 | if approvals >= rule.required_approvals { |
| 339 | return Vec::new(); |
| 340 | } |
| 341 | let from = rule.team.as_ref().map(|team| format!(" from @{}", team.trim().trim_start_matches('@'))).unwrap_or_default(); |
| 342 | let shown: Vec<&str> = touched.iter().take(3).map(|file| file.as_str()).collect(); |
| 343 | let more = if touched.len() > 3 { format!(" and {} more", touched.len() - 3) } else { String::new() }; |
| 344 | vec![Problem::new( |
| 345 | format!( |
| 346 | "It changes sensitive paths ({}{more}), which need {}{from} since its latest push; it has {approvals}.", |
| 347 | shown.join(", "), |
| 348 | plural(rule.required_approvals, "approval", "approvals") |
| 349 | ), |
| 350 | format!("Ask{} for a review.", if from.is_empty() { String::new() } else { from.replacen(" from", "", 1) }), |
| 351 | )] |
| 352 | } |
| 353 | Rule::MergeWindow(rule) => match window::closed(rule, facts.now_ms) { |
| 354 | Some(closed) => vec![Problem::new(window::explain(&closed), "Merge when the window opens, or ask someone who may bypass this ruleset.")], |
| 355 | None => Vec::new(), |
| 356 | }, |
| 357 | _ => Vec::new(), |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | /// How every applicable ruleset judges merging a pull request. |
| 362 | pub fn judge(rulesets: &[Applicable], default_branch: &str, facts: &MergeFacts<'_>) -> Vec<Judged> { |
| 363 | rulesets |
| 364 | .iter() |
| 365 | .filter(|ruleset| applies_to_ref(ruleset, &facts.git_ref, default_branch)) |
| 366 | .map(|ruleset| { |
| 367 | let mut judged = Judged::of(ruleset, &facts.git_ref, true); |
| 368 | for entry in &ruleset.rules { |
| 369 | if !entry.applies_to.covers(facts.agent_change) { |
| 370 | continue; |
| 371 | } |
| 372 | let kind = entry.rule.kind(); |
| 373 | for problem in rule_problems(&entry.rule, facts) { |
| 374 | judged.add(kind, problem); |
| 375 | } |
| 376 | } |
| 377 | judged |
| 378 | }) |
| 379 | .collect() |
| 380 | } |
| 381 | |
| 382 | /// Whether merging needs the pull request's commits read: a rule about |
| 383 | /// commits holds, for whoever made the change. |
| 384 | pub fn needs_commits(rulesets: &[Applicable], agent_change: bool) -> bool { |
| 385 | rulesets.iter().any(|ruleset| { |
| 386 | ruleset |
| 387 | .rules |
| 388 | .iter() |
| 389 | .any(|entry| entry.applies_to.covers(agent_change) && content::about_content(&entry.rule) && !matches!(entry.rule, Rule::SecretScanning(_))) |
| 390 | }) |
| 391 | } |
| 392 | |
| 393 | /// The teams rules name, for their people to be looked up. |
| 394 | pub fn named_teams(rulesets: &[Applicable]) -> Vec<String> { |
| 395 | let mut teams: Vec<String> = rulesets |
| 396 | .iter() |
| 397 | .flat_map(|ruleset| ruleset.rules.iter()) |
| 398 | .filter_map(|entry| match &entry.rule { |
| 399 | Rule::PathReview(rule) => rule.team.clone(), |
| 400 | _ => None, |
| 401 | }) |
| 402 | .collect(); |
| 403 | teams.sort(); |
| 404 | teams.dedup(); |
| 405 | teams |
| 406 | } |
| 407 | |
| 408 | /// What the active rules ask of a branch, in the terms g1t's lifecycle, |
| 409 | /// pull request page and merge queue use. Evaluate-mode rulesets add |
| 410 | /// nothing here: they never hold a pull request up. |
| 411 | #[derive(Clone, Debug, Default, PartialEq)] |
| 412 | pub struct Requirements { |
| 413 | /// Whether changes reach the branch only through pull requests. |
| 414 | pub pull_request: bool, |
| 415 | /// Every required check, by name, that holds for the files changed. |
| 416 | pub required_checks: Vec<String>, |
| 417 | pub strict: bool, |
| 418 | /// Whether every status checks rule lets a merger bypass its checks. |
| 419 | pub allow_bypass_on_merge: bool, |
| 420 | pub required_approvals: u32, |
| 421 | /// Whether every pull request rule counts agents' approvals. |
| 422 | pub count_agent_approvals: bool, |
| 423 | pub require_code_owner_review: bool, |
| 424 | /// The merge queue, if a rule requires it. |
| 425 | pub merge_queue: Option<MergeQueueRule>, |
| 426 | /// Whether g1t may land an agent's change here by itself, and the |
| 427 | /// confidence it needs to. |
| 428 | pub agent_auto_merge: bool, |
| 429 | pub auto_merge_confidence: Option<ConfidenceLevel>, |
| 430 | /// The highest cost cap below which an agent continues on its own. |
| 431 | pub cost_cap: Option<f64>, |
| 432 | } |
| 433 | |
| 434 | /// What the active rules hold for, stacked: the most restrictive wins. |
| 435 | pub fn requirements(rulesets: &[Applicable], git_ref: &str, default_branch: &str, agent_change: bool, files: &[String]) -> Requirements { |
| 436 | let mut found = Requirements { |
| 437 | count_agent_approvals: true, |
| 438 | allow_bypass_on_merge: true, |
| 439 | agent_auto_merge: true, |
| 440 | ..Requirements::default() |
| 441 | }; |
| 442 | let mut any_checks = false; |
| 443 | for ruleset in rulesets |
| 444 | .iter() |
| 445 | .filter(|ruleset| ruleset.enforcement == Enforcement::Active) |
| 446 | .filter(|ruleset| applies_to_ref(ruleset, git_ref, default_branch)) |
| 447 | { |
| 448 | for entry in ruleset.rules.iter().filter(|entry| entry.applies_to.covers(agent_change)) { |
| 449 | match &entry.rule { |
| 450 | Rule::PullRequest(rule) => { |
| 451 | found.pull_request = true; |
| 452 | found.required_approvals = found.required_approvals.max(rule.required_approvals); |
| 453 | found.count_agent_approvals &= rule.count_agent_approvals; |
| 454 | found.require_code_owner_review |= rule.require_code_owner_review; |
| 455 | } |
| 456 | Rule::RequiredStatusChecks(rule) if checks_hold(rule, files) => { |
| 457 | any_checks = true; |
| 458 | found.strict |= rule.strict; |
| 459 | found.allow_bypass_on_merge &= rule.allow_bypass_on_merge; |
| 460 | for check in &rule.checks { |
| 461 | let name = check.context.trim().to_owned(); |
| 462 | if !name.is_empty() && !found.required_checks.iter().any(|have| have.eq_ignore_ascii_case(&name)) { |
| 463 | found.required_checks.push(name); |
| 464 | } |
| 465 | } |
| 466 | } |
| 467 | Rule::MergeQueue(rule) => { |
| 468 | found.pull_request = true; |
| 469 | found.merge_queue = Some(match found.merge_queue.take() { |
| 470 | // Two queue rules: the smaller batches and the |
| 471 | // longer waits of either. |
| 472 | Some(have) => MergeQueueRule { |
| 473 | merge_method: have.merge_method, |
| 474 | max_entries_to_build: have.max_entries_to_build.min(rule.max_entries_to_build), |
| 475 | min_entries_to_merge: have.min_entries_to_merge.max(rule.min_entries_to_merge), |
| 476 | min_entries_wait_minutes: have.min_entries_wait_minutes.max(rule.min_entries_wait_minutes), |
| 477 | check_response_timeout_minutes: have.check_response_timeout_minutes.min(rule.check_response_timeout_minutes), |
| 478 | }, |
| 479 | None => rule.clone(), |
| 480 | }); |
| 481 | } |
| 482 | Rule::AgentAutoMerge(rule) => { |
| 483 | found.agent_auto_merge &= rule.allowed; |
| 484 | if let Some(minimum) = rule.minimum_confidence { |
| 485 | found.auto_merge_confidence = Some(match found.auto_merge_confidence { |
| 486 | Some(have) if level_rank(have) >= level_rank(minimum) => have, |
| 487 | _ => minimum, |
| 488 | }); |
| 489 | } |
| 490 | } |
| 491 | Rule::CostCap(rule) => { |
| 492 | found.cost_cap = Some(found.cost_cap.map_or(rule.max_usd, |have| have.min(rule.max_usd))); |
| 493 | } |
| 494 | _ => {} |
| 495 | } |
| 496 | } |
| 497 | } |
| 498 | if !any_checks { |
| 499 | // Nothing to bypass: the setting means nothing without checks. |
| 500 | found.allow_bypass_on_merge = true; |
| 501 | } |
| 502 | found |
| 503 | } |
| 504 | |
| 505 | /// Whether g1t may land an agent's change into the branch by itself, given |
| 506 | /// how sure of it g1t is; why not, if it may not. |
| 507 | pub fn auto_merge_refusal(requirements: &Requirements, confidence: Option<ConfidenceLevel>) -> Option<String> { |
| 508 | if !requirements.agent_auto_merge { |
| 509 | return Some("Rules for this branch do not let agents' changes merge by themselves.".to_owned()); |
| 510 | } |
| 511 | let minimum = requirements.auto_merge_confidence?; |
| 512 | if confidence.is_some_and(|level| level_rank(level) >= level_rank(minimum)) { |
| 513 | return None; |
| 514 | } |
| 515 | Some(format!("Rules for this branch let an agent's change merge by itself only at {} confidence or higher.", minimum.as_str())) |
| 516 | } |
| 517 | |
| 518 | /// Whether a violation is about checks or being up to date, which g1t's |
| 519 | /// lifecycle waits for on its own, rather than something people must do. |
| 520 | pub fn about_checks(rule: &str) -> bool { |
| 521 | matches!(rule, "required_status_checks" | "required_deployments") |
| 522 | } |
| 523 | |
| 524 | #[cfg(test)] |
| 525 | mod tests { |
| 526 | use super::*; |
| 527 | use crate::content::tests_support::commit; |
| 528 | use crate::outcome::{blocking, refused}; |
| 529 | use g1t_contracts::rules::{ |
| 530 | AppliesTo, BypassMode, ConfidenceRule, CostCapRule, DeploymentsRule, Level, MergeWindowRule, NoParameters, |
| 531 | PathReviewRule, Period, PullRequestRule, RefCondition, RequiredCheck, RuleEntry, Verdict as Outcome, |
| 532 | }; |
| 533 | |
| 534 | fn ruleset(rules: Vec<RuleEntry>) -> Applicable { |
| 535 | Applicable { |
| 536 | id: "rs_1".into(), |
| 537 | name: "Protect main".into(), |
| 538 | level: Level::Repository, |
| 539 | enforcement: Enforcement::Active, |
| 540 | target: g1t_contracts::rules::Target::Branch, |
| 541 | conditions: RefCondition { include: vec!["~DEFAULT_BRANCH".into()], exclude: Vec::new() }, |
| 542 | rules, |
| 543 | bypass: None, |
| 544 | } |
| 545 | } |
| 546 | |
| 547 | fn all(rule: Rule) -> RuleEntry { |
| 548 | RuleEntry::everyone(rule) |
| 549 | } |
| 550 | |
| 551 | fn review(id: &str, verdict: Verdict, at: &str) -> Review { |
| 552 | Review { reviewer_id: id.into(), username: id.into(), verdict, at: at.into(), agent: id == "g1t" } |
| 553 | } |
| 554 | |
| 555 | fn status(context: &str, state: &str) -> CommitStatus { |
| 556 | CommitStatus { context: context.into(), state: state.into(), description: None, target_url: None, updated_at: String::new(), source: None } |
| 557 | } |
| 558 | |
| 559 | fn facts<'a>(reviews: &'a [Review], statuses: &'a [CommitStatus], files: &'a [String]) -> MergeFacts<'a> { |
| 560 | MergeFacts { |
| 561 | git_ref: "refs/heads/main".into(), |
| 562 | owner_id: "ada", |
| 563 | reviews, |
| 564 | statuses, |
| 565 | files, |
| 566 | now_ms: 1_000, |
| 567 | method: Some(MergeMethod::Merge), |
| 568 | ..MergeFacts::default() |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | fn messages(judged: &[Judged]) -> Vec<String> { |
| 573 | blocking(judged).iter().map(|violation| violation.message.clone()).collect() |
| 574 | } |
| 575 | |
| 576 | #[test] |
| 577 | fn approvals_are_counted_as_the_rule_says() { |
| 578 | let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 2, ..PullRequestRule::default() }))])]; |
| 579 | let one = [review("bob", Verdict::Approve, "2026-10-07T10:00:00Z"), review("ada", Verdict::Approve, "2026-10-07T10:00:00Z")]; |
| 580 | assert_eq!(messages(&judge(&rules, "main", &facts(&one, &[], &[]))), vec!["It needs 2 approving reviews; it has 1."]); |
| 581 | let two = [review("bob", Verdict::Approve, "x"), review("g1t", Verdict::Approve, "x")]; |
| 582 | assert!(!refused(&judge(&rules, "main", &facts(&two, &[], &[])))); |
| 583 | let people_only = [ruleset(vec![all(Rule::PullRequest(PullRequestRule { |
| 584 | required_approvals: 2, |
| 585 | count_agent_approvals: false, |
| 586 | ..PullRequestRule::default() |
| 587 | }))])]; |
| 588 | assert_eq!( |
| 589 | messages(&judge(&people_only, "main", &facts(&two, &[], &[]))), |
| 590 | vec!["It needs 2 approving reviews from people; it has 1."] |
| 591 | ); |
| 592 | let blocked = [review("bob", Verdict::Approve, "x"), review("cy", Verdict::RequestChanges, "x")]; |
| 593 | assert_eq!(messages(&judge(&rules, "main", &facts(&blocked, &[], &[]))), vec!["A reviewer has asked for changes."]); |
| 594 | } |
| 595 | |
| 596 | #[test] |
| 597 | fn stale_approvals_and_the_last_push() { |
| 598 | let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule { |
| 599 | required_approvals: 1, |
| 600 | dismiss_stale_reviews_on_push: true, |
| 601 | require_last_push_approval: true, |
| 602 | ..PullRequestRule::default() |
| 603 | }))])]; |
| 604 | let before = [review("bob", Verdict::Approve, "2026-10-07T09:00:00Z")]; |
| 605 | let mut pushed = facts(&before, &[], &[]); |
| 606 | pushed.head_pushed_at = Some("2026-10-07T10:00:00Z"); |
| 607 | pushed.head_pushed_by = Some("bob"); |
| 608 | let found = messages(&judge(&rules, "main", &pushed)); |
| 609 | assert_eq!(found.len(), 2); |
| 610 | assert_eq!(found[0], "It needs 1 approving review since its latest push; it has 0."); |
| 611 | // Bob approves again, but he pushed last: someone else must. |
| 612 | let after = [review("bob", Verdict::Approve, "2026-10-07T11:00:00Z")]; |
| 613 | let mut again = facts(&after, &[], &[]); |
| 614 | again.head_pushed_at = Some("2026-10-07T10:00:00Z"); |
| 615 | again.head_pushed_by = Some("bob"); |
| 616 | assert_eq!( |
| 617 | messages(&judge(&rules, "main", &again)), |
| 618 | vec!["Its latest push has not been approved by someone other than whoever pushed it."] |
| 619 | ); |
| 620 | let other = [review("cy", Verdict::Approve, "2026-10-07T11:00:00Z")]; |
| 621 | let mut fine = facts(&other, &[], &[]); |
| 622 | fine.head_pushed_at = Some("2026-10-07T10:00:00Z"); |
| 623 | fine.head_pushed_by = Some("bob"); |
| 624 | assert!(!refused(&judge(&rules, "main", &fine))); |
| 625 | } |
| 626 | |
| 627 | #[test] |
| 628 | fn code_owners_and_merge_methods() { |
| 629 | let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule { |
| 630 | require_code_owner_review: true, |
| 631 | allowed_merge_methods: vec![MergeMethod::Squash], |
| 632 | ..PullRequestRule::default() |
| 633 | }))])]; |
| 634 | let mut waiting = facts(&[], &[], &[]); |
| 635 | waiting.code_owners_missing = Some("@acme/docs must approve changes to docs/."); |
| 636 | let found = messages(&judge(&rules, "main", &waiting)); |
| 637 | assert_eq!(found[0], "@acme/docs must approve changes to docs/."); |
| 638 | assert_eq!(found[1], "This branch allows only squash merges, and this one would be a merge merge."); |
| 639 | } |
| 640 | |
| 641 | #[test] |
| 642 | fn required_checks_pass_fail_wait_and_can_be_bypassed() { |
| 643 | let checks = |allow: bool| { |
| 644 | [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule { |
| 645 | checks: vec![RequiredCheck { context: "CI".into(), integration: None }, RequiredCheck { context: "Lint".into(), integration: None }], |
| 646 | allow_bypass_on_merge: allow, |
| 647 | ..StatusChecksRule::default() |
| 648 | }))])] |
| 649 | }; |
| 650 | let statuses = [status("CI / pull_request", "failure")]; |
| 651 | let found = messages(&judge(&checks(false), "main", &facts(&[], &statuses, &[]))); |
| 652 | assert_eq!(found, vec!["The required check CI failed.", "The required check Lint has not reported on its latest commit."]); |
| 653 | let mut ignoring = facts(&[], &statuses, &[]); |
| 654 | ignoring.ignore_checks = true; |
| 655 | assert!(refused(&judge(&checks(false), "main", &ignoring)), "not where the rule forbids it"); |
| 656 | assert!(!refused(&judge(&checks(true), "main", &ignoring))); |
| 657 | let green = [status("CI / pull_request", "success"), status("Lint / pull_request", "success")]; |
| 658 | assert!(!refused(&judge(&checks(false), "main", &facts(&[], &green, &[])))); |
| 659 | } |
| 660 | |
| 661 | #[test] |
| 662 | fn a_check_pinned_to_an_integration_counts_only_its_statuses() { |
| 663 | let rules = [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule { |
| 664 | checks: vec![RequiredCheck { context: "g1t / deploy".into(), integration: Some(Integration::Deployments) }], |
| 665 | ..StatusChecksRule::default() |
| 666 | }))])]; |
| 667 | // A workflow named "g1t" on a "deploy" event is not the deployment. |
| 668 | let mut forged = status("g1t / deploy", "success"); |
| 669 | forged.source = Some("actions".into()); |
| 670 | assert_eq!( |
| 671 | messages(&judge(&rules, "main", &facts(&[], &[forged], &[]))), |
| 672 | vec!["The required check g1t / deploy from deployments has not reported on its latest commit."] |
| 673 | ); |
| 674 | let real = status("g1t / deploy", "success"); |
| 675 | assert!(!refused(&judge(&rules, "main", &facts(&[], &[real], &[])))); |
| 676 | } |
| 677 | |
| 678 | #[test] |
| 679 | fn checks_required_only_for_some_paths() { |
| 680 | let rules = [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule { |
| 681 | checks: vec![RequiredCheck { context: "Terraform".into(), integration: None }], |
| 682 | paths: vec!["infra/**".into()], |
| 683 | ..StatusChecksRule::default() |
| 684 | }))])]; |
| 685 | let docs = vec!["docs/a.md".to_owned()]; |
| 686 | assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &docs)))); |
| 687 | let infra = vec!["infra/main.tf".to_owned()]; |
| 688 | assert!(refused(&judge(&rules, "main", &facts(&[], &[], &infra)))); |
| 689 | assert!(requirements(&rules, "refs/heads/main", "main", false, &docs).required_checks.is_empty()); |
| 690 | assert_eq!(requirements(&rules, "refs/heads/main", "main", false, &infra).required_checks, vec!["Terraform"]); |
| 691 | } |
| 692 | |
| 693 | #[test] |
| 694 | fn being_up_to_date_and_deployments() { |
| 695 | let rules = [ruleset(vec![ |
| 696 | all(Rule::RequiredStatusChecks(StatusChecksRule { strict: true, ..StatusChecksRule::default() })), |
| 697 | all(Rule::RequiredDeployments(DeploymentsRule { environments: vec!["preview".into(), "docs".into()] })), |
| 698 | ])]; |
| 699 | let statuses = [status("g1t / deploy", "success"), status("g1t / deploy (docs)", "pending")]; |
| 700 | let mut behind = facts(&[], &statuses, &[]); |
| 701 | behind.behind = true; |
| 702 | assert_eq!( |
| 703 | messages(&judge(&rules, "main", &behind)), |
| 704 | vec![ |
| 705 | "It is behind the branch it merges into, which requires pull requests to be up to date.", |
| 706 | "It has not deployed to docs successfully yet: the deployment is running." |
| 707 | ] |
| 708 | ); |
| 709 | } |
| 710 | |
| 711 | #[test] |
| 712 | fn commits_it_lands_are_checked_when_read() { |
| 713 | let rules = [ruleset(vec![all(Rule::RequiredLinearHistory(NoParameters {}))])]; |
| 714 | assert!(needs_commits(&rules, false)); |
| 715 | let mut merge = commit("abcdef12", "Merge main", &[]); |
| 716 | merge.parents = 2; |
| 717 | let inspected = InspectedCommits { commits: vec![merge], complete: true }; |
| 718 | let mut read = facts(&[], &[], &[]); |
| 719 | read.commits = Some(&inspected); |
| 720 | assert_eq!(blocking(&judge(&rules, "main", &read))[0].rule, "required_linear_history"); |
| 721 | let unread = InspectedCommits { commits: Vec::new(), complete: false }; |
| 722 | read.commits = Some(&unread); |
| 723 | assert!(refused(&judge(&rules, "main", &read))); |
| 724 | } |
| 725 | |
| 726 | #[test] |
| 727 | fn agent_changes_below_the_confidence_threshold_need_a_person() { |
| 728 | let rules = [ruleset(vec![all(Rule::ConfidenceThreshold(ConfidenceRule { minimum: ConfidenceLevel::High, required_approvals: 1 }))])]; |
| 729 | let mut agent = facts(&[], &[], &[]); |
| 730 | agent.agent_change = true; |
| 731 | agent.confidence = Some(ConfidenceLevel::Medium); |
| 732 | assert_eq!( |
| 733 | messages(&judge(&rules, "main", &agent)), |
| 734 | vec!["g1t rates this agent's change medium confidence; below high it needs 1 approval from a person."] |
| 735 | ); |
| 736 | agent.confidence = Some(ConfidenceLevel::High); |
| 737 | assert!(!refused(&judge(&rules, "main", &agent))); |
| 738 | agent.confidence = None; |
| 739 | assert!(refused(&judge(&rules, "main", &agent))); |
| 740 | let approved = [review("bob", Verdict::Approve, "x")]; |
| 741 | let mut seen = facts(&approved, &[], &[]); |
| 742 | seen.agent_change = true; |
| 743 | assert!(!refused(&judge(&rules, "main", &seen))); |
| 744 | // A g1t approval is not a person's. |
| 745 | let robot = [review("g1t", Verdict::Approve, "x")]; |
| 746 | let mut unseen = facts(&robot, &[], &[]); |
| 747 | unseen.agent_change = true; |
| 748 | assert!(refused(&judge(&rules, "main", &unseen))); |
| 749 | // A person's change is not rated. |
| 750 | assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &[])))); |
| 751 | } |
| 752 | |
| 753 | #[test] |
| 754 | fn rules_for_agents_and_for_people() { |
| 755 | let agents_need_a_human = RuleEntry { |
| 756 | rule: Rule::PullRequest(PullRequestRule { required_approvals: 1, count_agent_approvals: false, ..PullRequestRule::default() }), |
| 757 | applies_to: AppliesTo::Agents, |
| 758 | }; |
| 759 | let rules = [ruleset(vec![agents_need_a_human])]; |
| 760 | let robot = [review("g1t", Verdict::Approve, "x")]; |
| 761 | let mut agent = facts(&robot, &[], &[]); |
| 762 | agent.agent_change = true; |
| 763 | assert_eq!(messages(&judge(&rules, "main", &agent)), vec!["It needs 1 approving review from people; it has 0."]); |
| 764 | assert!(!refused(&judge(&rules, "main", &facts(&robot, &[], &[]))), "people's changes are not held"); |
| 765 | } |
| 766 | |
| 767 | #[test] |
| 768 | fn a_cost_cap_holds_until_a_person_approves() { |
| 769 | let rules = [ruleset(vec![all(Rule::CostCap(CostCapRule { max_usd: 5.0 }))])]; |
| 770 | let mut costly = facts(&[], &[], &[]); |
| 771 | costly.spent_usd = 7.5; |
| 772 | assert_eq!( |
| 773 | messages(&judge(&rules, "main", &costly)), |
| 774 | vec!["Agents have spent $7.50 on this pull request, over its $5.00 cap."] |
| 775 | ); |
| 776 | costly.spent_usd = 4.0; |
| 777 | assert!(!refused(&judge(&rules, "main", &costly))); |
| 778 | let approved = [review("bob", Verdict::Approve, "x")]; |
| 779 | let mut seen = facts(&approved, &[], &[]); |
| 780 | seen.spent_usd = 7.5; |
| 781 | assert!(!refused(&judge(&rules, "main", &seen))); |
| 782 | assert_eq!(requirements(&rules, "refs/heads/main", "main", false, &[]).cost_cap, Some(5.0)); |
| 783 | } |
| 784 | |
| 785 | #[test] |
| 786 | fn sensitive_paths_need_their_teams_approval() { |
| 787 | let rules = [ruleset(vec![all(Rule::PathReview(PathReviewRule { |
| 788 | paths: vec!["infra/**".into(), "*.tf".into()], |
| 789 | required_approvals: 2, |
| 790 | team: Some("acme/platform".into()), |
| 791 | }))])]; |
| 792 | let files = vec!["infra/main.tf".to_owned(), "src/lib.rs".to_owned()]; |
| 793 | let mut teams = HashMap::new(); |
| 794 | teams.insert("acme/platform".to_owned(), vec!["bob".to_owned(), "cy".to_owned()]); |
| 795 | let reviews = [review("bob", Verdict::Approve, "x"), review("dee", Verdict::Approve, "x")]; |
| 796 | let mut one = facts(&reviews, &[], &files); |
| 797 | one.team_members = Some(&teams); |
| 798 | assert_eq!( |
| 799 | messages(&judge(&rules, "main", &one)), |
| 800 | vec!["It changes sensitive paths (infra/main.tf), which need 2 approvals from @acme/platform since its latest push; it has 1."] |
| 801 | ); |
| 802 | let both = [review("bob", Verdict::Approve, "x"), review("cy", Verdict::Approve, "x")]; |
| 803 | let mut two = facts(&both, &[], &files); |
| 804 | two.team_members = Some(&teams); |
| 805 | assert!(!refused(&judge(&rules, "main", &two))); |
| 806 | let docs = vec!["docs/a.md".to_owned()]; |
| 807 | assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &docs)))); |
| 808 | assert_eq!(named_teams(&rules), vec!["acme/platform"]); |
| 809 | } |
| 810 | |
| 811 | #[test] |
| 812 | fn restricting_updates_restricts_merges() { |
| 813 | let rules = [ruleset(vec![all(Rule::Update(NoParameters {}))])]; |
| 814 | assert_eq!( |
| 815 | messages(&judge(&rules, "main", &facts(&[], &[], &[]))), |
| 816 | vec!["Only people this ruleset lets bypass it may change main, merges included."] |
| 817 | ); |
| 818 | let mut bypassed = rules[0].clone(); |
| 819 | bypassed.bypass = Some(BypassMode::Always); |
| 820 | assert!(!refused(&judge(&[bypassed], "main", &facts(&[], &[], &[])))); |
| 821 | } |
| 822 | |
| 823 | #[test] |
| 824 | fn a_merge_freeze_holds_merges() { |
| 825 | let rules = [ruleset(vec![all(Rule::MergeWindow(MergeWindowRule { |
| 826 | freezes: vec![Period { start: "1970-01-01T00:00:00Z".into(), end: None, reason: "Incident".into() }], |
| 827 | ..MergeWindowRule::default() |
| 828 | }))])]; |
| 829 | assert_eq!(messages(&judge(&rules, "main", &facts(&[], &[], &[]))), vec!["Merging is frozen (Incident) until the freeze is lifted."]); |
| 830 | } |
| 831 | |
| 832 | #[test] |
| 833 | fn bypassing_for_pull_requests_covers_merges() { |
| 834 | let mut rules = ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 1, ..PullRequestRule::default() }))]); |
| 835 | rules.bypass = Some(BypassMode::PullRequests); |
| 836 | let judged = judge(&[rules], "main", &facts(&[], &[], &[])); |
| 837 | assert!(!refused(&judged)); |
| 838 | assert_eq!(judged[0].verdict(), Outcome::Bypass); |
| 839 | } |
| 840 | |
| 841 | #[test] |
| 842 | fn requirements_stack_to_the_most_restrictive() { |
| 843 | let a = ruleset(vec![ |
| 844 | all(Rule::PullRequest(PullRequestRule { required_approvals: 1, ..PullRequestRule::default() })), |
| 845 | all(Rule::RequiredStatusChecks(StatusChecksRule { |
| 846 | checks: vec![RequiredCheck { context: "CI".into(), integration: None }], |
| 847 | allow_bypass_on_merge: true, |
| 848 | ..StatusChecksRule::default() |
| 849 | })), |
| 850 | all(Rule::MergeQueue(MergeQueueRule { max_entries_to_build: 8, ..MergeQueueRule::default() })), |
| 851 | ]); |
| 852 | let mut b = ruleset(vec![ |
| 853 | all(Rule::PullRequest(PullRequestRule { required_approvals: 3, count_agent_approvals: false, require_code_owner_review: true, ..PullRequestRule::default() })), |
| 854 | all(Rule::RequiredStatusChecks(StatusChecksRule { |
| 855 | checks: vec![RequiredCheck { context: "ci".into(), integration: None }, RequiredCheck { context: "Lint".into(), integration: None }], |
| 856 | strict: true, |
| 857 | ..StatusChecksRule::default() |
| 858 | })), |
| 859 | all(Rule::MergeQueue(MergeQueueRule { max_entries_to_build: 2, ..MergeQueueRule::default() })), |
| 860 | ]); |
| 861 | b.id = "rs_2".into(); |
| 862 | let mut dry = ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 6, ..PullRequestRule::default() }))]); |
| 863 | dry.enforcement = Enforcement::Evaluate; |
| 864 | let found = requirements(&[a, b, dry], "refs/heads/main", "main", false, &[]); |
| 865 | assert!(found.pull_request); |
| 866 | assert_eq!(found.required_approvals, 3, "evaluate mode adds nothing"); |
| 867 | assert!(!found.count_agent_approvals && found.require_code_owner_review && found.strict); |
| 868 | assert!(!found.allow_bypass_on_merge); |
| 869 | assert_eq!(found.required_checks, vec!["CI", "Lint"]); |
| 870 | assert_eq!(found.merge_queue.unwrap().max_entries_to_build, 2); |
| 871 | assert!(requirements(&[], "refs/heads/main", "main", false, &[]).allow_bypass_on_merge); |
| 872 | } |
| 873 | |
| 874 | #[test] |
| 875 | fn agent_auto_merge_by_branch_and_confidence() { |
| 876 | let rules = [ruleset(vec![all(Rule::AgentAutoMerge(g1t_contracts::rules::AgentAutoMergeRule { |
| 877 | allowed: true, |
| 878 | minimum_confidence: Some(ConfidenceLevel::High), |
| 879 | }))])]; |
| 880 | let found = requirements(&rules, "refs/heads/main", "main", true, &[]); |
| 881 | assert!(auto_merge_refusal(&found, Some(ConfidenceLevel::Medium)).is_some()); |
| 882 | assert_eq!(auto_merge_refusal(&found, Some(ConfidenceLevel::High)), None); |
| 883 | let off = [ruleset(vec![all(Rule::AgentAutoMerge(g1t_contracts::rules::AgentAutoMergeRule { allowed: false, minimum_confidence: None }))])]; |
| 884 | assert!(auto_merge_refusal(&requirements(&off, "refs/heads/main", "main", true, &[]), Some(ConfidenceLevel::High)).is_some()); |
| 885 | assert_eq!(auto_merge_refusal(&requirements(&[], "refs/heads/main", "main", true, &[]), None), None); |
| 886 | } |
| 887 | |
| 888 | #[test] |
| 889 | fn statuses_come_from_their_integration() { |
| 890 | assert_eq!(integration_of(&status("CI / pull_request", "success")), Integration::Actions); |
| 891 | assert_eq!(integration_of(&status("g1t / deploy (docs)", "success")), Integration::Deployments); |
| 892 | assert_eq!(integration_of(&status("Code scanning", "success")), Integration::Security); |
| 893 | let mut recorded = status("CI / push", "success"); |
| 894 | recorded.source = Some("security".into()); |
| 895 | assert_eq!(integration_of(&recorded), Integration::Security); |
| 896 | assert_eq!(deployment_context("preview"), "g1t / deploy"); |
| 897 | assert_eq!(deployment_context("docs"), "g1t / deploy (docs)"); |
| 898 | } |
| 899 | } |