Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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