Skip to content

g1t/crates/rules/src/merge.rs

933 lines44,121 bytesCodeBlame
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
10use std::collections::HashMap;
11
12use g1t_contracts::rules::{
13 Applicable, ConfidenceLevel, Enforcement, InspectedCommits, Integration, MergeMethod, MergeQueueRule, Rule,
14 StatusChecksRule,
15};
16use g1t_contracts::work::{CommitStatus, RequiredState, Verdict, check_name, required_checks};
17
18use crate::content::{self, Problem};
19use crate::glob;
20use crate::outcome::Judged;
21use crate::select::applies_to_ref;
22use crate::window;
23
24/// One reviewer's latest verdict.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub 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)]
38pub 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.
75pub 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.
93pub 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`.
108pub 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
112fn 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.
118fn 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
127fn 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
190fn 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.
230pub 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/// The check a deployment reported to `environment` sets on its commit,
240/// through the API or by a g1t Actions job (services/deployments).
241pub fn reported_deployment_context(environment: &str) -> String {
242 format!("deploy / {}", environment.trim())
243}
244
245fn level_rank(level: ConfidenceLevel) -> u8 {
246 match level {
247 ConfidenceLevel::Low => 0,
248 ConfidenceLevel::Medium => 1,
249 ConfidenceLevel::High => 2,
250 }
251}
252
253/// The problems one rule finds in a merge.
254fn rule_problems(rule: &Rule, facts: &MergeFacts<'_>) -> Vec<Problem> {
255 match rule {
256 // Restricting updates restricts merges too: a merge moves the branch.
257 Rule::Update(_) => {
258 let branch = facts.git_ref.strip_prefix("refs/heads/").unwrap_or(&facts.git_ref);
259 vec![Problem::new(
260 format!("Only people this ruleset lets bypass it may change {branch}, merges included."),
261 "Ask someone who may bypass this ruleset to merge it.",
262 )]
263 }
264 Rule::PullRequest(rule) => pull_request_problems(rule, facts),
265 Rule::RequiredStatusChecks(rule) => checks_problems(rule, facts),
266 Rule::RequiredDeployments(rule) => rule
267 .environments
268 .iter()
269 .filter(|environment| !environment.trim().is_empty())
270 .filter_map(|environment| {
271 // A g1t.page build's check, or a deployment reported to the
272 // environment from anywhere (`deploy / <environment>`): one
273 // that succeeded meets the rule.
274 let contexts = [deployment_context(environment), reported_deployment_context(environment)];
275 let matching: Vec<&str> = facts
276 .statuses
277 .iter()
278 .filter(|status| contexts.iter().any(|context| status.context.eq_ignore_ascii_case(context)))
279 .map(|status| status.state.as_str())
280 .collect();
281 let state = matching
282 .iter()
283 .find(|state| **state == "success")
284 .or_else(|| matching.iter().find(|state| **state == "pending"))
285 .or_else(|| matching.first())
286 .copied();
287 (state != Some("success")).then(|| {
288 Problem::new(
289 format!(
290 "It has not deployed to {} successfully{}.",
291 environment.trim(),
292 match state {
293 Some("pending") => " yet: the deployment is running",
294 Some(_) => ": the deployment failed",
295 None => "",
296 }
297 ),
298 "Wait for its deployment, or fix what made it fail and push.",
299 )
300 })
301 })
302 .collect(),
303 rule if content::about_content(rule) => match facts.commits {
304 Some(inspected) => content::problems(rule, &inspected.commits, inspected.complete),
305 None => Vec::new(),
306 },
307 Rule::ConfidenceThreshold(rule) if facts.agent_change => {
308 let below = facts.confidence.is_none_or(|level| level_rank(level) < level_rank(rule.minimum));
309 let approvals = people_approving(facts, false).count() as u32;
310 if below && approvals < rule.required_approvals.max(1) {
311 let rated = match facts.confidence {
312 Some(level) => format!("g1t rates this agent's change {} confidence", level.as_str()),
313 None => "g1t has not rated this agent's change yet".to_owned(),
314 };
315 vec![Problem::new(
316 format!(
317 "{rated}; below {} it needs {}.",
318 rule.minimum.as_str(),
319 plural(rule.required_approvals.max(1), "approval from a person", "approvals from people")
320 ),
321 "Review the change and approve it if it is right.",
322 )]
323 } else {
324 Vec::new()
325 }
326 }
327 Rule::CostCap(rule) if facts.spent_usd > rule.max_usd => {
328 if people_approving(facts, false).next().is_some() {
329 Vec::new()
330 } else {
331 vec![Problem::new(
332 format!(
333 "Agents have spent ${:.2} on this pull request, over its ${:.2} cap.",
334 facts.spent_usd, rule.max_usd
335 ),
336 "A person must approve it before it merges or its agent continues.",
337 )]
338 }
339 }
340 Rule::PathReview(rule) => {
341 let touched: Vec<&String> = facts
342 .files
343 .iter()
344 .filter(|file| rule.paths.iter().any(|pattern| glob::path_matches(pattern, file)))
345 .collect();
346 if touched.is_empty() || rule.required_approvals == 0 {
347 return Vec::new();
348 }
349 let members = rule.team.as_ref().map(|team| {
350 facts
351 .team_members
352 .and_then(|teams| teams.get(&team.trim().trim_start_matches('@').to_lowercase()).cloned())
353 .unwrap_or_default()
354 });
355 let approvals = people_approving(facts, true)
356 .filter(|review| members.as_ref().is_none_or(|members| members.contains(&review.username.to_lowercase())))
357 .count() as u32;
358 if approvals >= rule.required_approvals {
359 return Vec::new();
360 }
361 let from = rule.team.as_ref().map(|team| format!(" from @{}", team.trim().trim_start_matches('@'))).unwrap_or_default();
362 let shown: Vec<&str> = touched.iter().take(3).map(|file| file.as_str()).collect();
363 let more = if touched.len() > 3 { format!(" and {} more", touched.len() - 3) } else { String::new() };
364 vec![Problem::new(
365 format!(
366 "It changes sensitive paths ({}{more}), which need {}{from} since its latest push; it has {approvals}.",
367 shown.join(", "),
368 plural(rule.required_approvals, "approval", "approvals")
369 ),
370 format!("Ask{} for a review.", if from.is_empty() { String::new() } else { from.replacen(" from", "", 1) }),
371 )]
372 }
373 Rule::MergeWindow(rule) => match window::closed(rule, facts.now_ms) {
374 Some(closed) => vec![Problem::new(window::explain(&closed), "Merge when the window opens, or ask someone who may bypass this ruleset.")],
375 None => Vec::new(),
376 },
377 _ => Vec::new(),
378 }
379}
380
381/// How every applicable ruleset judges merging a pull request.
382pub fn judge(rulesets: &[Applicable], default_branch: &str, facts: &MergeFacts<'_>) -> Vec<Judged> {
383 rulesets
384 .iter()
385 .filter(|ruleset| applies_to_ref(ruleset, &facts.git_ref, default_branch))
386 .map(|ruleset| {
387 let mut judged = Judged::of(ruleset, &facts.git_ref, true);
388 for entry in &ruleset.rules {
389 if !entry.applies_to.covers(facts.agent_change) {
390 continue;
391 }
392 let kind = entry.rule.kind();
393 for problem in rule_problems(&entry.rule, facts) {
394 judged.add(kind, problem);
395 }
396 }
397 judged
398 })
399 .collect()
400}
401
402/// Whether merging needs the pull request's commits read: a rule about
403/// commits holds, for whoever made the change.
404pub fn needs_commits(rulesets: &[Applicable], agent_change: bool) -> bool {
405 rulesets.iter().any(|ruleset| {
406 ruleset
407 .rules
408 .iter()
409 .any(|entry| entry.applies_to.covers(agent_change) && content::about_content(&entry.rule) && !matches!(entry.rule, Rule::SecretScanning(_)))
410 })
411}
412
413/// The teams rules name, for their people to be looked up.
414pub fn named_teams(rulesets: &[Applicable]) -> Vec<String> {
415 let mut teams: Vec<String> = rulesets
416 .iter()
417 .flat_map(|ruleset| ruleset.rules.iter())
418 .filter_map(|entry| match &entry.rule {
419 Rule::PathReview(rule) => rule.team.clone(),
420 _ => None,
421 })
422 .collect();
423 teams.sort();
424 teams.dedup();
425 teams
426}
427
428/// What the active rules ask of a branch, in the terms g1t's lifecycle,
429/// pull request page and merge queue use. Evaluate-mode rulesets add
430/// nothing here: they never hold a pull request up.
431#[derive(Clone, Debug, Default, PartialEq)]
432pub struct Requirements {
433 /// Whether changes reach the branch only through pull requests.
434 pub pull_request: bool,
435 /// Every required check, by name, that holds for the files changed.
436 pub required_checks: Vec<String>,
437 pub strict: bool,
438 /// Whether every status checks rule lets a merger bypass its checks.
439 pub allow_bypass_on_merge: bool,
440 pub required_approvals: u32,
441 /// Whether every pull request rule counts agents' approvals.
442 pub count_agent_approvals: bool,
443 pub require_code_owner_review: bool,
444 /// The merge queue, if a rule requires it.
445 pub merge_queue: Option<MergeQueueRule>,
446 /// Whether g1t may land an agent's change here by itself, and the
447 /// confidence it needs to.
448 pub agent_auto_merge: bool,
449 pub auto_merge_confidence: Option<ConfidenceLevel>,
450 /// The highest cost cap below which an agent continues on its own.
451 pub cost_cap: Option<f64>,
452}
453
454/// What the active rules hold for, stacked: the most restrictive wins.
455pub fn requirements(rulesets: &[Applicable], git_ref: &str, default_branch: &str, agent_change: bool, files: &[String]) -> Requirements {
456 let mut found = Requirements {
457 count_agent_approvals: true,
458 allow_bypass_on_merge: true,
459 agent_auto_merge: true,
460 ..Requirements::default()
461 };
462 let mut any_checks = false;
463 for ruleset in rulesets
464 .iter()
465 .filter(|ruleset| ruleset.enforcement == Enforcement::Active)
466 .filter(|ruleset| applies_to_ref(ruleset, git_ref, default_branch))
467 {
468 for entry in ruleset.rules.iter().filter(|entry| entry.applies_to.covers(agent_change)) {
469 match &entry.rule {
470 Rule::PullRequest(rule) => {
471 found.pull_request = true;
472 found.required_approvals = found.required_approvals.max(rule.required_approvals);
473 found.count_agent_approvals &= rule.count_agent_approvals;
474 found.require_code_owner_review |= rule.require_code_owner_review;
475 }
476 Rule::RequiredStatusChecks(rule) if checks_hold(rule, files) => {
477 any_checks = true;
478 found.strict |= rule.strict;
479 found.allow_bypass_on_merge &= rule.allow_bypass_on_merge;
480 for check in &rule.checks {
481 let name = check.context.trim().to_owned();
482 if !name.is_empty() && !found.required_checks.iter().any(|have| have.eq_ignore_ascii_case(&name)) {
483 found.required_checks.push(name);
484 }
485 }
486 }
487 Rule::MergeQueue(rule) => {
488 found.pull_request = true;
489 found.merge_queue = Some(match found.merge_queue.take() {
490 // Two queue rules: the smaller batches and the
491 // longer waits of either.
492 Some(have) => MergeQueueRule {
493 merge_method: have.merge_method,
494 max_entries_to_build: have.max_entries_to_build.min(rule.max_entries_to_build),
495 min_entries_to_merge: have.min_entries_to_merge.max(rule.min_entries_to_merge),
496 min_entries_wait_minutes: have.min_entries_wait_minutes.max(rule.min_entries_wait_minutes),
497 check_response_timeout_minutes: have.check_response_timeout_minutes.min(rule.check_response_timeout_minutes),
498 },
499 None => rule.clone(),
500 });
501 }
502 Rule::AgentAutoMerge(rule) => {
503 found.agent_auto_merge &= rule.allowed;
504 if let Some(minimum) = rule.minimum_confidence {
505 found.auto_merge_confidence = Some(match found.auto_merge_confidence {
506 Some(have) if level_rank(have) >= level_rank(minimum) => have,
507 _ => minimum,
508 });
509 }
510 }
511 Rule::CostCap(rule) => {
512 found.cost_cap = Some(found.cost_cap.map_or(rule.max_usd, |have| have.min(rule.max_usd)));
513 }
514 _ => {}
515 }
516 }
517 }
518 if !any_checks {
519 // Nothing to bypass: the setting means nothing without checks.
520 found.allow_bypass_on_merge = true;
521 }
522 found
523}
524
525/// Whether g1t may land an agent's change into the branch by itself, given
526/// how sure of it g1t is; why not, if it may not.
527pub fn auto_merge_refusal(requirements: &Requirements, confidence: Option<ConfidenceLevel>) -> Option<String> {
528 if !requirements.agent_auto_merge {
529 return Some("Rules for this branch do not let agents' changes merge by themselves.".to_owned());
530 }
531 let minimum = requirements.auto_merge_confidence?;
532 if confidence.is_some_and(|level| level_rank(level) >= level_rank(minimum)) {
533 return None;
534 }
535 Some(format!("Rules for this branch let an agent's change merge by itself only at {} confidence or higher.", minimum.as_str()))
536}
537
538/// Whether a violation is about checks or being up to date, which g1t's
539/// lifecycle waits for on its own, rather than something people must do.
540pub fn about_checks(rule: &str) -> bool {
541 matches!(rule, "required_status_checks" | "required_deployments")
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use crate::content::tests_support::commit;
548 use crate::outcome::{blocking, refused};
549 use g1t_contracts::rules::{
550 AppliesTo, BypassMode, ConfidenceRule, CostCapRule, DeploymentsRule, Level, MergeWindowRule, NoParameters,
551 PathReviewRule, Period, PullRequestRule, RefCondition, RequiredCheck, RuleEntry, Verdict as Outcome,
552 };
553
554 fn ruleset(rules: Vec<RuleEntry>) -> Applicable {
555 Applicable {
556 id: "rs_1".into(),
557 name: "Protect main".into(),
558 level: Level::Repository,
559 enforcement: Enforcement::Active,
560 target: g1t_contracts::rules::Target::Branch,
561 conditions: RefCondition { include: vec!["~DEFAULT_BRANCH".into()], exclude: Vec::new() },
562 rules,
563 bypass: None,
564 }
565 }
566
567 fn all(rule: Rule) -> RuleEntry {
568 RuleEntry::everyone(rule)
569 }
570
571 fn review(id: &str, verdict: Verdict, at: &str) -> Review {
572 Review { reviewer_id: id.into(), username: id.into(), verdict, at: at.into(), agent: id == "g1t" }
573 }
574
575 fn status(context: &str, state: &str) -> CommitStatus {
576 CommitStatus { context: context.into(), state: state.into(), description: None, target_url: None, updated_at: String::new(), source: None, check_run_id: None }
577 }
578
579 fn facts<'a>(reviews: &'a [Review], statuses: &'a [CommitStatus], files: &'a [String]) -> MergeFacts<'a> {
580 MergeFacts {
581 git_ref: "refs/heads/main".into(),
582 owner_id: "ada",
583 reviews,
584 statuses,
585 files,
586 now_ms: 1_000,
587 method: Some(MergeMethod::Merge),
588 ..MergeFacts::default()
589 }
590 }
591
592 fn messages(judged: &[Judged]) -> Vec<String> {
593 blocking(judged).iter().map(|violation| violation.message.clone()).collect()
594 }
595
596 #[test]
597 fn approvals_are_counted_as_the_rule_says() {
598 let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 2, ..PullRequestRule::default() }))])];
599 let one = [review("bob", Verdict::Approve, "2026-10-07T10:00:00Z"), review("ada", Verdict::Approve, "2026-10-07T10:00:00Z")];
600 assert_eq!(messages(&judge(&rules, "main", &facts(&one, &[], &[]))), vec!["It needs 2 approving reviews; it has 1."]);
601 let two = [review("bob", Verdict::Approve, "x"), review("g1t", Verdict::Approve, "x")];
602 assert!(!refused(&judge(&rules, "main", &facts(&two, &[], &[]))));
603 let people_only = [ruleset(vec![all(Rule::PullRequest(PullRequestRule {
604 required_approvals: 2,
605 count_agent_approvals: false,
606 ..PullRequestRule::default()
607 }))])];
608 assert_eq!(
609 messages(&judge(&people_only, "main", &facts(&two, &[], &[]))),
610 vec!["It needs 2 approving reviews from people; it has 1."]
611 );
612 let blocked = [review("bob", Verdict::Approve, "x"), review("cy", Verdict::RequestChanges, "x")];
613 assert_eq!(messages(&judge(&rules, "main", &facts(&blocked, &[], &[]))), vec!["A reviewer has asked for changes."]);
614 }
615
616 #[test]
617 fn stale_approvals_and_the_last_push() {
618 let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule {
619 required_approvals: 1,
620 dismiss_stale_reviews_on_push: true,
621 require_last_push_approval: true,
622 ..PullRequestRule::default()
623 }))])];
624 let before = [review("bob", Verdict::Approve, "2026-10-07T09:00:00Z")];
625 let mut pushed = facts(&before, &[], &[]);
626 pushed.head_pushed_at = Some("2026-10-07T10:00:00Z");
627 pushed.head_pushed_by = Some("bob");
628 let found = messages(&judge(&rules, "main", &pushed));
629 assert_eq!(found.len(), 2);
630 assert_eq!(found[0], "It needs 1 approving review since its latest push; it has 0.");
631 // Bob approves again, but he pushed last: someone else must.
632 let after = [review("bob", Verdict::Approve, "2026-10-07T11:00:00Z")];
633 let mut again = facts(&after, &[], &[]);
634 again.head_pushed_at = Some("2026-10-07T10:00:00Z");
635 again.head_pushed_by = Some("bob");
636 assert_eq!(
637 messages(&judge(&rules, "main", &again)),
638 vec!["Its latest push has not been approved by someone other than whoever pushed it."]
639 );
640 let other = [review("cy", Verdict::Approve, "2026-10-07T11:00:00Z")];
641 let mut fine = facts(&other, &[], &[]);
642 fine.head_pushed_at = Some("2026-10-07T10:00:00Z");
643 fine.head_pushed_by = Some("bob");
644 assert!(!refused(&judge(&rules, "main", &fine)));
645 }
646
647 #[test]
648 fn code_owners_and_merge_methods() {
649 let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule {
650 require_code_owner_review: true,
651 allowed_merge_methods: vec![MergeMethod::Squash],
652 ..PullRequestRule::default()
653 }))])];
654 let mut waiting = facts(&[], &[], &[]);
655 waiting.code_owners_missing = Some("@acme/docs must approve changes to docs/.");
656 let found = messages(&judge(&rules, "main", &waiting));
657 assert_eq!(found[0], "@acme/docs must approve changes to docs/.");
658 assert_eq!(found[1], "This branch allows only squash merges, and this one would be a merge merge.");
659 }
660
661 #[test]
662 fn required_checks_pass_fail_wait_and_can_be_bypassed() {
663 let checks = |allow: bool| {
664 [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule {
665 checks: vec![RequiredCheck { context: "CI".into(), integration: None }, RequiredCheck { context: "Lint".into(), integration: None }],
666 allow_bypass_on_merge: allow,
667 ..StatusChecksRule::default()
668 }))])]
669 };
670 let statuses = [status("CI / pull_request", "failure")];
671 let found = messages(&judge(&checks(false), "main", &facts(&[], &statuses, &[])));
672 assert_eq!(found, vec!["The required check CI failed.", "The required check Lint has not reported on its latest commit."]);
673 let mut ignoring = facts(&[], &statuses, &[]);
674 ignoring.ignore_checks = true;
675 assert!(refused(&judge(&checks(false), "main", &ignoring)), "not where the rule forbids it");
676 assert!(!refused(&judge(&checks(true), "main", &ignoring)));
677 let green = [status("CI / pull_request", "success"), status("Lint / pull_request", "success")];
678 assert!(!refused(&judge(&checks(false), "main", &facts(&[], &green, &[]))));
679 }
680
681 #[test]
682 fn a_check_pinned_to_an_integration_counts_only_its_statuses() {
683 let rules = [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule {
684 checks: vec![RequiredCheck { context: "g1t / deploy".into(), integration: Some(Integration::Deployments) }],
685 ..StatusChecksRule::default()
686 }))])];
687 // A workflow named "g1t" on a "deploy" event is not the deployment.
688 let mut forged = status("g1t / deploy", "success");
689 forged.source = Some("actions".into());
690 assert_eq!(
691 messages(&judge(&rules, "main", &facts(&[], &[forged], &[]))),
692 vec!["The required check g1t / deploy from deployments has not reported on its latest commit."]
693 );
694 let real = status("g1t / deploy", "success");
695 assert!(!refused(&judge(&rules, "main", &facts(&[], &[real], &[]))));
696 }
697
698 #[test]
699 fn checks_required_only_for_some_paths() {
700 let rules = [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule {
701 checks: vec![RequiredCheck { context: "Terraform".into(), integration: None }],
702 paths: vec!["infra/**".into()],
703 ..StatusChecksRule::default()
704 }))])];
705 let docs = vec!["docs/a.md".to_owned()];
706 assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &docs))));
707 let infra = vec!["infra/main.tf".to_owned()];
708 assert!(refused(&judge(&rules, "main", &facts(&[], &[], &infra))));
709 assert!(requirements(&rules, "refs/heads/main", "main", false, &docs).required_checks.is_empty());
710 assert_eq!(requirements(&rules, "refs/heads/main", "main", false, &infra).required_checks, vec!["Terraform"]);
711 }
712
713 #[test]
714 fn being_up_to_date_and_deployments() {
715 let rules = [ruleset(vec![
716 all(Rule::RequiredStatusChecks(StatusChecksRule { strict: true, ..StatusChecksRule::default() })),
717 all(Rule::RequiredDeployments(DeploymentsRule { environments: vec!["preview".into(), "docs".into()] })),
718 ])];
719 let statuses = [status("g1t / deploy", "success"), status("g1t / deploy (docs)", "pending")];
720 let mut behind = facts(&[], &statuses, &[]);
721 behind.behind = true;
722 assert_eq!(
723 messages(&judge(&rules, "main", &behind)),
724 vec![
725 "It is behind the branch it merges into, which requires pull requests to be up to date.",
726 "It has not deployed to docs successfully yet: the deployment is running."
727 ]
728 );
729 }
730
731 #[test]
732 fn a_deployment_reported_from_anywhere_meets_the_rule() {
733 let rules = [ruleset(vec![all(Rule::RequiredDeployments(DeploymentsRule { environments: vec!["staging".into()] }))])];
734 let reported = [status("deploy / staging", "success")];
735 assert!(messages(&judge(&rules, "main", &facts(&[], &reported, &[]))).is_empty());
736 let failed = [status("deploy / Staging", "failure")];
737 assert_eq!(
738 messages(&judge(&rules, "main", &facts(&[], &failed, &[]))),
739 vec!["It has not deployed to staging successfully: the deployment failed."]
740 );
741 let none: [g1t_contracts::work::CommitStatus; 0] = [];
742 assert_eq!(messages(&judge(&rules, "main", &facts(&[], &none, &[]))), vec!["It has not deployed to staging successfully."]);
743 }
744
745 #[test]
746 fn commits_it_lands_are_checked_when_read() {
747 let rules = [ruleset(vec![all(Rule::RequiredLinearHistory(NoParameters {}))])];
748 assert!(needs_commits(&rules, false));
749 let mut merge = commit("abcdef12", "Merge main", &[]);
750 merge.parents = 2;
751 let inspected = InspectedCommits { commits: vec![merge], complete: true };
752 let mut read = facts(&[], &[], &[]);
753 read.commits = Some(&inspected);
754 assert_eq!(blocking(&judge(&rules, "main", &read))[0].rule, "required_linear_history");
755 let unread = InspectedCommits { commits: Vec::new(), complete: false };
756 read.commits = Some(&unread);
757 assert!(refused(&judge(&rules, "main", &read)));
758 }
759
760 #[test]
761 fn agent_changes_below_the_confidence_threshold_need_a_person() {
762 let rules = [ruleset(vec![all(Rule::ConfidenceThreshold(ConfidenceRule { minimum: ConfidenceLevel::High, required_approvals: 1 }))])];
763 let mut agent = facts(&[], &[], &[]);
764 agent.agent_change = true;
765 agent.confidence = Some(ConfidenceLevel::Medium);
766 assert_eq!(
767 messages(&judge(&rules, "main", &agent)),
768 vec!["g1t rates this agent's change medium confidence; below high it needs 1 approval from a person."]
769 );
770 agent.confidence = Some(ConfidenceLevel::High);
771 assert!(!refused(&judge(&rules, "main", &agent)));
772 agent.confidence = None;
773 assert!(refused(&judge(&rules, "main", &agent)));
774 let approved = [review("bob", Verdict::Approve, "x")];
775 let mut seen = facts(&approved, &[], &[]);
776 seen.agent_change = true;
777 assert!(!refused(&judge(&rules, "main", &seen)));
778 // A g1t approval is not a person's.
779 let robot = [review("g1t", Verdict::Approve, "x")];
780 let mut unseen = facts(&robot, &[], &[]);
781 unseen.agent_change = true;
782 assert!(refused(&judge(&rules, "main", &unseen)));
783 // A person's change is not rated.
784 assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &[]))));
785 }
786
787 #[test]
788 fn rules_for_agents_and_for_people() {
789 let agents_need_a_human = RuleEntry {
790 rule: Rule::PullRequest(PullRequestRule { required_approvals: 1, count_agent_approvals: false, ..PullRequestRule::default() }),
791 applies_to: AppliesTo::Agents,
792 };
793 let rules = [ruleset(vec![agents_need_a_human])];
794 let robot = [review("g1t", Verdict::Approve, "x")];
795 let mut agent = facts(&robot, &[], &[]);
796 agent.agent_change = true;
797 assert_eq!(messages(&judge(&rules, "main", &agent)), vec!["It needs 1 approving review from people; it has 0."]);
798 assert!(!refused(&judge(&rules, "main", &facts(&robot, &[], &[]))), "people's changes are not held");
799 }
800
801 #[test]
802 fn a_cost_cap_holds_until_a_person_approves() {
803 let rules = [ruleset(vec![all(Rule::CostCap(CostCapRule { max_usd: 5.0 }))])];
804 let mut costly = facts(&[], &[], &[]);
805 costly.spent_usd = 7.5;
806 assert_eq!(
807 messages(&judge(&rules, "main", &costly)),
808 vec!["Agents have spent $7.50 on this pull request, over its $5.00 cap."]
809 );
810 costly.spent_usd = 4.0;
811 assert!(!refused(&judge(&rules, "main", &costly)));
812 let approved = [review("bob", Verdict::Approve, "x")];
813 let mut seen = facts(&approved, &[], &[]);
814 seen.spent_usd = 7.5;
815 assert!(!refused(&judge(&rules, "main", &seen)));
816 assert_eq!(requirements(&rules, "refs/heads/main", "main", false, &[]).cost_cap, Some(5.0));
817 }
818
819 #[test]
820 fn sensitive_paths_need_their_teams_approval() {
821 let rules = [ruleset(vec![all(Rule::PathReview(PathReviewRule {
822 paths: vec!["infra/**".into(), "*.tf".into()],
823 required_approvals: 2,
824 team: Some("acme/platform".into()),
825 }))])];
826 let files = vec!["infra/main.tf".to_owned(), "src/lib.rs".to_owned()];
827 let mut teams = HashMap::new();
828 teams.insert("acme/platform".to_owned(), vec!["bob".to_owned(), "cy".to_owned()]);
829 let reviews = [review("bob", Verdict::Approve, "x"), review("dee", Verdict::Approve, "x")];
830 let mut one = facts(&reviews, &[], &files);
831 one.team_members = Some(&teams);
832 assert_eq!(
833 messages(&judge(&rules, "main", &one)),
834 vec!["It changes sensitive paths (infra/main.tf), which need 2 approvals from @acme/platform since its latest push; it has 1."]
835 );
836 let both = [review("bob", Verdict::Approve, "x"), review("cy", Verdict::Approve, "x")];
837 let mut two = facts(&both, &[], &files);
838 two.team_members = Some(&teams);
839 assert!(!refused(&judge(&rules, "main", &two)));
840 let docs = vec!["docs/a.md".to_owned()];
841 assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &docs))));
842 assert_eq!(named_teams(&rules), vec!["acme/platform"]);
843 }
844
845 #[test]
846 fn restricting_updates_restricts_merges() {
847 let rules = [ruleset(vec![all(Rule::Update(NoParameters {}))])];
848 assert_eq!(
849 messages(&judge(&rules, "main", &facts(&[], &[], &[]))),
850 vec!["Only people this ruleset lets bypass it may change main, merges included."]
851 );
852 let mut bypassed = rules[0].clone();
853 bypassed.bypass = Some(BypassMode::Always);
854 assert!(!refused(&judge(&[bypassed], "main", &facts(&[], &[], &[]))));
855 }
856
857 #[test]
858 fn a_merge_freeze_holds_merges() {
859 let rules = [ruleset(vec![all(Rule::MergeWindow(MergeWindowRule {
860 freezes: vec![Period { start: "1970-01-01T00:00:00Z".into(), end: None, reason: "Incident".into() }],
861 ..MergeWindowRule::default()
862 }))])];
863 assert_eq!(messages(&judge(&rules, "main", &facts(&[], &[], &[]))), vec!["Merging is frozen (Incident) until the freeze is lifted."]);
864 }
865
866 #[test]
867 fn bypassing_for_pull_requests_covers_merges() {
868 let mut rules = ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 1, ..PullRequestRule::default() }))]);
869 rules.bypass = Some(BypassMode::PullRequests);
870 let judged = judge(&[rules], "main", &facts(&[], &[], &[]));
871 assert!(!refused(&judged));
872 assert_eq!(judged[0].verdict(), Outcome::Bypass);
873 }
874
875 #[test]
876 fn requirements_stack_to_the_most_restrictive() {
877 let a = ruleset(vec![
878 all(Rule::PullRequest(PullRequestRule { required_approvals: 1, ..PullRequestRule::default() })),
879 all(Rule::RequiredStatusChecks(StatusChecksRule {
880 checks: vec![RequiredCheck { context: "CI".into(), integration: None }],
881 allow_bypass_on_merge: true,
882 ..StatusChecksRule::default()
883 })),
884 all(Rule::MergeQueue(MergeQueueRule { max_entries_to_build: 8, ..MergeQueueRule::default() })),
885 ]);
886 let mut b = ruleset(vec![
887 all(Rule::PullRequest(PullRequestRule { required_approvals: 3, count_agent_approvals: false, require_code_owner_review: true, ..PullRequestRule::default() })),
888 all(Rule::RequiredStatusChecks(StatusChecksRule {
889 checks: vec![RequiredCheck { context: "ci".into(), integration: None }, RequiredCheck { context: "Lint".into(), integration: None }],
890 strict: true,
891 ..StatusChecksRule::default()
892 })),
893 all(Rule::MergeQueue(MergeQueueRule { max_entries_to_build: 2, ..MergeQueueRule::default() })),
894 ]);
895 b.id = "rs_2".into();
896 let mut dry = ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 6, ..PullRequestRule::default() }))]);
897 dry.enforcement = Enforcement::Evaluate;
898 let found = requirements(&[a, b, dry], "refs/heads/main", "main", false, &[]);
899 assert!(found.pull_request);
900 assert_eq!(found.required_approvals, 3, "evaluate mode adds nothing");
901 assert!(!found.count_agent_approvals && found.require_code_owner_review && found.strict);
902 assert!(!found.allow_bypass_on_merge);
903 assert_eq!(found.required_checks, vec!["CI", "Lint"]);
904 assert_eq!(found.merge_queue.unwrap().max_entries_to_build, 2);
905 assert!(requirements(&[], "refs/heads/main", "main", false, &[]).allow_bypass_on_merge);
906 }
907
908 #[test]
909 fn agent_auto_merge_by_branch_and_confidence() {
910 let rules = [ruleset(vec![all(Rule::AgentAutoMerge(g1t_contracts::rules::AgentAutoMergeRule {
911 allowed: true,
912 minimum_confidence: Some(ConfidenceLevel::High),
913 }))])];
914 let found = requirements(&rules, "refs/heads/main", "main", true, &[]);
915 assert!(auto_merge_refusal(&found, Some(ConfidenceLevel::Medium)).is_some());
916 assert_eq!(auto_merge_refusal(&found, Some(ConfidenceLevel::High)), None);
917 let off = [ruleset(vec![all(Rule::AgentAutoMerge(g1t_contracts::rules::AgentAutoMergeRule { allowed: false, minimum_confidence: None }))])];
918 assert!(auto_merge_refusal(&requirements(&off, "refs/heads/main", "main", true, &[]), Some(ConfidenceLevel::High)).is_some());
919 assert_eq!(auto_merge_refusal(&requirements(&[], "refs/heads/main", "main", true, &[]), None), None);
920 }
921
922 #[test]
923 fn statuses_come_from_their_integration() {
924 assert_eq!(integration_of(&status("CI / pull_request", "success")), Integration::Actions);
925 assert_eq!(integration_of(&status("g1t / deploy (docs)", "success")), Integration::Deployments);
926 assert_eq!(integration_of(&status("Code scanning", "success")), Integration::Security);
927 let mut recorded = status("CI / push", "success");
928 recorded.source = Some("security".into());
929 assert_eq!(integration_of(&recorded), Integration::Security);
930 assert_eq!(deployment_context("preview"), "g1t / deploy");
931 assert_eq!(deployment_context("docs"), "g1t / deploy (docs)");
932 }
933}