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
818 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 stale_approvals_and_the_last_push() {
553 let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule {
554 required_approvals: 1,
555 dismiss_stale_reviews_on_push: true,
556 require_last_push_approval: true,
557 ..PullRequestRule::default()
558 }))])];
559 let before = [review("bob", Verdict::Approve, "2026-10-07T09:00:00Z")];
560 let mut pushed = facts(&before, &[], &[]);
561 pushed.head_pushed_at = Some("2026-10-07T10:00:00Z");
562 pushed.head_pushed_by = Some("bob");
563 let found = messages(&judge(&rules, "main", &pushed));
564 assert_eq!(found.len(), 2);
565 assert_eq!(found[0], "It needs 1 approving review since its latest push; it has 0.");
566 // Bob approves again, but he pushed last: someone else must.
567 let after = [review("bob", Verdict::Approve, "2026-10-07T11:00:00Z")];
568 let mut again = facts(&after, &[], &[]);
569 again.head_pushed_at = Some("2026-10-07T10:00:00Z");
570 again.head_pushed_by = Some("bob");
571 assert_eq!(
572 messages(&judge(&rules, "main", &again)),
573 vec!["Its latest push has not been approved by someone other than whoever pushed it."]
574 );
575 let other = [review("cy", Verdict::Approve, "2026-10-07T11:00:00Z")];
576 let mut fine = facts(&other, &[], &[]);
577 fine.head_pushed_at = Some("2026-10-07T10:00:00Z");
578 fine.head_pushed_by = Some("bob");
579 assert!(!refused(&judge(&rules, "main", &fine)));
580 }
581
582 #[test]
583 fn code_owners_and_merge_methods() {
584 let rules = [ruleset(vec![all(Rule::PullRequest(PullRequestRule {
585 require_code_owner_review: true,
586 allowed_merge_methods: vec![MergeMethod::Squash],
587 ..PullRequestRule::default()
588 }))])];
589 let mut waiting = facts(&[], &[], &[]);
590 waiting.code_owners_missing = Some("@acme/docs must approve changes to docs/.");
591 let found = messages(&judge(&rules, "main", &waiting));
592 assert_eq!(found[0], "@acme/docs must approve changes to docs/.");
593 assert_eq!(found[1], "This branch allows only squash merges, and this one would be a merge merge.");
594 }
595
596 #[test]
597 fn required_checks_pass_fail_wait_and_can_be_bypassed() {
598 let checks = |allow: bool| {
599 [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule {
600 checks: vec![RequiredCheck { context: "CI".into(), integration: None }, RequiredCheck { context: "Lint".into(), integration: None }],
601 allow_bypass_on_merge: allow,
602 ..StatusChecksRule::default()
603 }))])]
604 };
605 let statuses = [status("CI / pull_request", "failure")];
606 let found = messages(&judge(&checks(false), "main", &facts(&[], &statuses, &[])));
607 assert_eq!(found, vec!["The required check CI failed.", "The required check Lint has not reported on its latest commit."]);
608 let mut ignoring = facts(&[], &statuses, &[]);
609 ignoring.ignore_checks = true;
610 assert!(refused(&judge(&checks(false), "main", &ignoring)), "not where the rule forbids it");
611 assert!(!refused(&judge(&checks(true), "main", &ignoring)));
612 let green = [status("CI / pull_request", "success"), status("Lint / pull_request", "success")];
613 assert!(!refused(&judge(&checks(false), "main", &facts(&[], &green, &[]))));
614 }
615
616 #[test]
617 fn a_check_pinned_to_an_integration_counts_only_its_statuses() {
618 let rules = [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule {
619 checks: vec![RequiredCheck { context: "g1t / deploy".into(), integration: Some(Integration::Deployments) }],
620 ..StatusChecksRule::default()
621 }))])];
622 // A workflow named "g1t" on a "deploy" event is not the deployment.
623 let mut forged = status("g1t / deploy", "success");
624 forged.source = Some("actions".into());
625 assert_eq!(
626 messages(&judge(&rules, "main", &facts(&[], &[forged], &[]))),
627 vec!["The required check g1t / deploy from deployments has not reported on its latest commit."]
628 );
629 let real = status("g1t / deploy", "success");
630 assert!(!refused(&judge(&rules, "main", &facts(&[], &[real], &[]))));
631 }
632
633 #[test]
634 fn checks_required_only_for_some_paths() {
635 let rules = [ruleset(vec![all(Rule::RequiredStatusChecks(StatusChecksRule {
636 checks: vec![RequiredCheck { context: "Terraform".into(), integration: None }],
637 paths: vec!["infra/**".into()],
638 ..StatusChecksRule::default()
639 }))])];
640 let docs = vec!["docs/a.md".to_owned()];
641 assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &docs))));
642 let infra = vec!["infra/main.tf".to_owned()];
643 assert!(refused(&judge(&rules, "main", &facts(&[], &[], &infra))));
644 assert!(requirements(&rules, "refs/heads/main", "main", false, &docs).required_checks.is_empty());
645 assert_eq!(requirements(&rules, "refs/heads/main", "main", false, &infra).required_checks, vec!["Terraform"]);
646 }
647
648 #[test]
649 fn being_up_to_date_and_deployments() {
650 let rules = [ruleset(vec![
651 all(Rule::RequiredStatusChecks(StatusChecksRule { strict: true, ..StatusChecksRule::default() })),
652 all(Rule::RequiredDeployments(DeploymentsRule { environments: vec!["preview".into(), "docs".into()] })),
653 ])];
654 let statuses = [status("g1t / deploy", "success"), status("g1t / deploy (docs)", "pending")];
655 let mut behind = facts(&[], &statuses, &[]);
656 behind.behind = true;
657 assert_eq!(
658 messages(&judge(&rules, "main", &behind)),
659 vec![
660 "It is behind the branch it merges into, which requires pull requests to be up to date.",
661 "It has not deployed to docs successfully yet: the deployment is running."
662 ]
663 );
664 }
665
666 #[test]
667 fn a_deployment_reported_from_anywhere_meets_the_rule() {
668 let rules = [ruleset(vec![all(Rule::RequiredDeployments(DeploymentsRule { environments: vec!["staging".into()] }))])];
669 let reported = [status("deploy / staging", "success")];
670 assert!(messages(&judge(&rules, "main", &facts(&[], &reported, &[]))).is_empty());
671 let failed = [status("deploy / Staging", "failure")];
672 assert_eq!(
673 messages(&judge(&rules, "main", &facts(&[], &failed, &[]))),
674 vec!["It has not deployed to staging successfully: the deployment failed."]
675 );
676 let none: [g1t_contracts::work::CommitStatus; 0] = [];
677 assert_eq!(messages(&judge(&rules, "main", &facts(&[], &none, &[]))), vec!["It has not deployed to staging successfully."]);
678 }
679
680 #[test]
681 fn commits_it_lands_are_checked_when_read() {
682 let rules = [ruleset(vec![all(Rule::RequiredLinearHistory(NoParameters {}))])];
683 assert!(needs_commits(&rules, false));
684 let mut merge = commit("abcdef12", "Merge main", &[]);
685 merge.parents = 2;
686 let inspected = InspectedCommits { commits: vec![merge], complete: true };
687 let mut read = facts(&[], &[], &[]);
688 read.commits = Some(&inspected);
689 assert_eq!(blocking(&judge(&rules, "main", &read))[0].rule, "required_linear_history");
690 let unread = InspectedCommits { commits: Vec::new(), complete: false };
691 read.commits = Some(&unread);
692 assert!(refused(&judge(&rules, "main", &read)));
693 }
694
695 #[test]
696 fn rules_for_agents_and_for_people() {
697 let agents_need_a_human = RuleEntry {
698 rule: Rule::PullRequest(PullRequestRule { required_approvals: 1, count_agent_approvals: false, ..PullRequestRule::default() }),
699 applies_to: AppliesTo::Agents,
700 };
701 let rules = [ruleset(vec![agents_need_a_human])];
702 let robot = [review("g1t", Verdict::Approve, "x")];
703 let mut agent = facts(&robot, &[], &[]);
704 agent.agent_change = true;
705 assert_eq!(messages(&judge(&rules, "main", &agent)), vec!["It needs 1 approving review from people; it has 0."]);
706 assert!(!refused(&judge(&rules, "main", &facts(&robot, &[], &[]))), "people's changes are not held");
707 }
708
709 #[test]
710 fn sensitive_paths_need_their_teams_approval() {
711 let rules = [ruleset(vec![all(Rule::PathReview(PathReviewRule {
712 paths: vec!["infra/**".into(), "*.tf".into()],
713 required_approvals: 2,
714 team: Some("acme/platform".into()),
715 }))])];
716 let files = vec!["infra/main.tf".to_owned(), "src/lib.rs".to_owned()];
717 let mut teams = HashMap::new();
718 teams.insert("acme/platform".to_owned(), vec!["bob".to_owned(), "cy".to_owned()]);
719 let reviews = [review("bob", Verdict::Approve, "x"), review("dee", Verdict::Approve, "x")];
720 let mut one = facts(&reviews, &[], &files);
721 one.team_members = Some(&teams);
722 assert_eq!(
723 messages(&judge(&rules, "main", &one)),
724 vec!["It changes sensitive paths (infra/main.tf), which need 2 approvals from @acme/platform since its latest push; it has 1."]
725 );
726 let both = [review("bob", Verdict::Approve, "x"), review("cy", Verdict::Approve, "x")];
727 let mut two = facts(&both, &[], &files);
728 two.team_members = Some(&teams);
729 assert!(!refused(&judge(&rules, "main", &two)));
730 let docs = vec!["docs/a.md".to_owned()];
731 assert!(!refused(&judge(&rules, "main", &facts(&[], &[], &docs))));
732 assert_eq!(named_teams(&rules), vec!["acme/platform"]);
733 }
734
735 #[test]
736 fn restricting_updates_restricts_merges() {
737 let rules = [ruleset(vec![all(Rule::Update(NoParameters {}))])];
738 assert_eq!(
739 messages(&judge(&rules, "main", &facts(&[], &[], &[]))),
740 vec!["Only people this ruleset lets bypass it may change main, merges included."]
741 );
742 let mut bypassed = rules[0].clone();
743 bypassed.bypass = Some(BypassMode::Always);
744 assert!(!refused(&judge(&[bypassed], "main", &facts(&[], &[], &[]))));
745 }
746
747 #[test]
748 fn a_merge_freeze_holds_merges() {
749 let rules = [ruleset(vec![all(Rule::MergeWindow(MergeWindowRule {
750 freezes: vec![Period { start: "1970-01-01T00:00:00Z".into(), end: None, reason: "Incident".into() }],
751 ..MergeWindowRule::default()
752 }))])];
753 assert_eq!(messages(&judge(&rules, "main", &facts(&[], &[], &[]))), vec!["Merging is frozen (Incident) until the freeze is lifted."]);
754 }
755
756 #[test]
757 fn bypassing_for_pull_requests_covers_merges() {
758 let mut rules = ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 1, ..PullRequestRule::default() }))]);
759 rules.bypass = Some(BypassMode::PullRequests);
760 let judged = judge(&[rules], "main", &facts(&[], &[], &[]));
761 assert!(!refused(&judged));
762 assert_eq!(judged[0].verdict(), Outcome::Bypass);
763 }
764
765 #[test]
766 fn requirements_stack_to_the_most_restrictive() {
767 let a = ruleset(vec![
768 all(Rule::PullRequest(PullRequestRule { required_approvals: 1, ..PullRequestRule::default() })),
769 all(Rule::RequiredStatusChecks(StatusChecksRule {
770 checks: vec![RequiredCheck { context: "CI".into(), integration: None }],
771 allow_bypass_on_merge: true,
772 ..StatusChecksRule::default()
773 })),
774 all(Rule::MergeQueue(MergeQueueRule { max_entries_to_build: 8, ..MergeQueueRule::default() })),
775 ]);
776 let mut b = ruleset(vec![
777 all(Rule::PullRequest(PullRequestRule { required_approvals: 3, count_agent_approvals: false, require_code_owner_review: true, ..PullRequestRule::default() })),
778 all(Rule::RequiredStatusChecks(StatusChecksRule {
779 checks: vec![RequiredCheck { context: "ci".into(), integration: None }, RequiredCheck { context: "Lint".into(), integration: None }],
780 strict: true,
781 ..StatusChecksRule::default()
782 })),
783 all(Rule::MergeQueue(MergeQueueRule { max_entries_to_build: 2, ..MergeQueueRule::default() })),
784 ]);
785 b.id = "rs_2".into();
786 let mut dry = ruleset(vec![all(Rule::PullRequest(PullRequestRule { required_approvals: 6, ..PullRequestRule::default() }))]);
787 dry.enforcement = Enforcement::Evaluate;
788 let found = requirements(&[a, b, dry], "refs/heads/main", "main", false, &[]);
789 assert!(found.pull_request);
790 assert_eq!(found.required_approvals, 3, "evaluate mode adds nothing");
791 assert!(!found.count_agent_approvals && found.require_code_owner_review && found.strict);
792 assert!(!found.allow_bypass_on_merge);
793 assert_eq!(found.required_checks, vec!["CI", "Lint"]);
794 assert_eq!(found.merge_queue.unwrap().max_entries_to_build, 2);
795 assert!(requirements(&[], "refs/heads/main", "main", false, &[]).allow_bypass_on_merge);
796 }
797
798 #[test]
799 fn agent_auto_merge_by_branch() {
800 let on = [ruleset(vec![all(Rule::AgentAutoMerge(g1t_contracts::rules::AgentAutoMergeRule { allowed: true }))])];
801 assert_eq!(auto_merge_refusal(&requirements(&on, "refs/heads/main", "main", true, &[])), None);
802 let off = [ruleset(vec![all(Rule::AgentAutoMerge(g1t_contracts::rules::AgentAutoMergeRule { allowed: false }))])];
803 assert!(auto_merge_refusal(&requirements(&off, "refs/heads/main", "main", true, &[])).is_some());
804 assert_eq!(auto_merge_refusal(&requirements(&[], "refs/heads/main", "main", true, &[])), None);
805 }
806
807 #[test]
808 fn statuses_come_from_their_integration() {
809 assert_eq!(integration_of(&status("CI / pull_request", "success")), Integration::Actions);
810 assert_eq!(integration_of(&status("g1t / deploy (docs)", "success")), Integration::Deployments);
811 assert_eq!(integration_of(&status("Code scanning", "success")), Integration::Security);
812 let mut recorded = status("CI / push", "success");
813 recorded.source = Some("security".into());
814 assert_eq!(integration_of(&recorded), Integration::Security);
815 assert_eq!(deployment_context("preview"), "g1t / deploy");
816 assert_eq!(deployment_context("docs"), "g1t / deploy (docs)");
817 }
818}