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