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.

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