Skip to content

g1t/crates/rules/src/merge.rs

899 lines42,315 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
239fn 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.
248fn 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.
362pub 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.
384pub 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.
394pub 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)]
412pub 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.
435pub 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.
507pub 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.
520pub fn about_checks(rule: &str) -> bool {
521 matches!(rule, "required_status_checks" | "required_deployments")
522}
523
524#[cfg(test)]
525mod 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}