use anyhow::{Result, bail};
use crate::pull_request::MergeAttempt;
use crate::ci::{self, Ci, FailedChecks};
use crate::failed_run::PolicyRefusal;
use crate::run::Goal;
use crate::worktree::{ForeignCommits, Merge};
use super::Stage;
use crate::session::Kind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Upstream {
BaseBranch,
IssueBranch,
}
pub(super) enum Repair<'a> {
Conflict(Upstream),
CiFix(&'a FailedChecks),
Review { from: &'a str },
}
pub(super) trait Outside {
type Pending;
fn stage(&mut self, stage: Stage);
fn merge_base_branch(&mut self) -> Result<Merge<Self::Pending>>;
fn merge_new_commits(&mut self, observed: ForeignCommits) -> Result<Merge<Self::Pending>>;
fn ensure_merged(&mut self, pending: &Self::Pending) -> Result<()>;
fn push(&mut self) -> Result<()>;
fn head(&mut self) -> Result<String>;
fn same_tree_as(&mut self, commit: &str) -> Result<bool>;
fn base_branch_moved(&mut self) -> Result<bool>;
fn new_commits_on_origin(&mut self) -> Result<ForeignCommits>;
fn assess_ci(&mut self, head: &str, base_commit: Option<&str>) -> Result<Ci>;
fn ci_fix_completed(&mut self, head: String, failed: FailedChecks);
fn repair(&mut self, kind: Kind, repair: Repair) -> Result<()>;
fn sees_inherited_failures(&mut self) -> bool;
fn base_fix(&mut self, base_commit: &str, failed: &FailedChecks) -> Result<()>;
fn pr_mergeable(&mut self) -> Result<bool>;
fn ensure_pr_ready_and_mergeable(&mut self) -> Result<()>;
fn merge(&mut self, head: &str) -> Result<MergeAttempt>;
fn interrupt_requested(&mut self) -> bool;
fn progress(&mut self, line: String);
}
pub(super) fn take_to_goal(outside: &mut impl Outside, base: &str, goal: Goal) -> Result<()> {
let mut repair_loop = RepairLoop {
outside,
base,
goal,
budgets: Budgets::default(),
};
let mut watched = repair_loop.rounds()?;
loop {
let outside = &mut *repair_loop.outside;
outside.stage(if goal == Goal::Merged {
Stage::SelfMerge
} else {
Stage::PullRequestReadiness
});
outside.ensure_pr_ready_and_mergeable()?;
if outside.interrupt_requested() {
bail!("interrupted");
}
if goal == Goal::ReadyForReview {
return Ok(());
}
outside.progress(format!("merging the PR into {base}"));
outside.stage(Stage::SelfMerge);
let error = match outside.merge(&watched)? {
MergeAttempt::Merged => return Ok(()),
MergeAttempt::Refused(error) => error,
};
outside.progress(format!("the merge failed: {error:#}"));
match repair_loop.round_after_failed_merge(&watched)? {
Round::NewHead(head) => watched = head,
Round::NothingToFix => {
repair_loop.outside.stage(Stage::SelfMerge);
repair_loop.outside.ensure_pr_ready_and_mergeable()?;
return Err(error.context(PolicyRefusal::NothingToFix));
}
}
}
}
const MAX_REPAIRS: usize = 5;
const MAX_UPSTREAM_MOVES: usize = 5;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct Budgets {
repairs: usize,
upstream_moves: usize,
}
impl Budgets {
fn next_repair(&mut self, cause: &str) -> Result<(Kind, String)> {
if self.repairs == MAX_REPAIRS {
bail!("repairs exhausted: {cause}");
}
self.repairs += 1;
let line = format!("{cause}; starting Repair {} of {MAX_REPAIRS}", self.repairs);
Ok((Kind::Repair(self.repairs), line))
}
fn count_upstream_move(&mut self, upstream: &str) -> Result<()> {
if self.upstream_moves == MAX_UPSTREAM_MOVES {
bail!("{upstream} kept moving: merged it again {MAX_UPSTREAM_MOVES} times");
}
self.upstream_moves += 1;
Ok(())
}
fn count_base_move(&mut self, base: &str, when: &str) -> Result<String> {
let upstream = format!("origin/{base}");
self.count_upstream_move(&upstream)?;
Ok(format!("{upstream} moved {when}; merging it again"))
}
fn count_foreign_commits(&mut self, upstream: &str) -> Result<String> {
self.count_upstream_move(upstream)?;
Ok(format!("{upstream} has new commits; merging them in"))
}
}
enum Round {
NewHead(String),
NothingToFix,
}
struct RepairLoop<'a, O> {
outside: &'a mut O,
base: &'a str,
goal: Goal,
budgets: Budgets,
}
impl<O: Outside> RepairLoop<'_, O> {
fn repair(&mut self, cause: &str, repair: Repair) -> Result<()> {
self.outside.stage(Stage::RepairLoopRound);
let (kind, line) = self.budgets.next_repair(cause)?;
self.outside.progress(line);
self.outside.stage(Stage::RepairSession);
self.outside.repair(kind, repair)
}
fn pr_merges_cleanly(&mut self) -> Result<bool> {
self.outside.stage(Stage::PullRequestReadiness);
self.outside.pr_mergeable()
}
fn count_base_move(&mut self, when: &str) -> Result<()> {
let line = self.budgets.count_base_move(self.base, when)?;
self.outside.progress(line);
Ok(())
}
fn rounds(&mut self) -> Result<String> {
let base = self.base;
loop {
self.outside.stage(Stage::RepairLoopRound);
if self.goal == Goal::Merged {
self.take_in_foreign_commits()?;
}
let base_commit = match self.outside.merge_base_branch()? {
Merge::Clean { commit } => commit,
Merge::Conflicted(pending) => {
self.repair("conflict", Repair::Conflict(Upstream::BaseBranch))?;
self.outside.ensure_merged(&pending)?;
continue;
}
};
self.outside.stage(Stage::Push);
self.outside.push()?;
let head = self.outside.head()?;
let compared_with = self
.outside
.sees_inherited_failures()
.then_some(base_commit.as_str());
self.outside.stage(Stage::CiWatch);
let ci = self.outside.assess_ci(&head, compared_with)?;
match ci {
Ci::Absent | Ci::Passed => {
if self.outside.base_branch_moved()? {
if self.goal == Goal::ReadyForReview {
if self.pr_merges_cleanly()? {
self.outside.progress(format!(
"origin/{base} moved while CI ran; leaving the PR for \
review on {}, which merges cleanly",
ci::short(&head)
));
return Ok(head);
}
self.outside.stage(Stage::RepairLoopRound);
}
self.count_base_move("while CI ran")?;
} else if self.goal == Goal::ReadyForReview
|| self.outside.new_commits_on_origin()?.commits().is_empty()
{
return Ok(head);
}
}
Ci::Failed(failed) => {
if !failed.inherited.is_empty() {
self.outside.progress(format!(
"Inherited failures (also failing on {base} at {}): {}",
ci::short(&base_commit),
ci::check_names(&failed.inherited)
));
}
if failed.own.is_empty() {
if self.outside.base_branch_moved()? {
self.count_base_move("while CI ran")?;
continue;
}
self.outside.stage(Stage::BaseFix);
self.outside.base_fix(&base_commit, &failed)?;
continue;
}
self.repair("CI red", Repair::CiFix(&failed))?;
self.outside.ci_fix_completed(head, failed);
}
}
}
}
fn take_in_foreign_commits(&mut self) -> Result<()> {
loop {
let foreign = self.outside.new_commits_on_origin()?;
if foreign.commits().is_empty() {
return Ok(());
}
let upstream = foreign.upstream().to_string();
let own_head = foreign.local_head().to_string();
let line = self.budgets.count_foreign_commits(&upstream)?;
self.outside.progress(line);
for sha in foreign.commits() {
self.outside
.progress(format!("merging new commit {sha} from {upstream}"));
}
if let Merge::Conflicted(pending) = self.outside.merge_new_commits(foreign)? {
self.repair(
&format!("conflict with new commits on {upstream}"),
Repair::Conflict(Upstream::IssueBranch),
)?;
self.outside.ensure_merged(&pending)?;
}
if !self.outside.same_tree_as(&own_head)? {
self.repair(
&format!("new commits on {upstream} to review"),
Repair::Review { from: &own_head },
)?;
}
}
}
fn round_after_failed_merge(&mut self, watched: &str) -> Result<Round> {
self.outside.stage(Stage::RepairLoopRound);
let before = self.budgets;
if self.outside.base_branch_moved()? {
self.count_base_move("since the merge was tried")?;
}
let head = self.rounds()?;
Ok(if head == watched && self.budgets == before {
Round::NothingToFix
} else {
Round::NewHead(head)
})
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use anyhow::anyhow;
use super::*;
use crate::github::{Check, Conclusion};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Did {
MergeBase,
MergeNewCommits(String),
CheckMerged(&'static str),
Push,
Assess(String, Option<String>),
CiFixCompleted(String, Vec<String>, Vec<String>),
Repair(Kind, Started),
BaseFix(String, Vec<String>),
ReadMergeable,
CheckPr,
Merge(String),
Line(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Started {
Conflict(Upstream),
CiFix(Vec<String>, Vec<String>),
Review(String),
}
enum Is {
Absent,
Passed,
Failed(&'static [&'static str], &'static [&'static str]),
}
impl Is {
fn ci(&self) -> Ci {
let checks = |names: &[&str]| -> Vec<Check> {
names
.iter()
.map(|name| Check {
name: name.to_string(),
conclusion: Conclusion::Failure,
url: None,
job: None,
})
.collect()
};
match self {
Is::Absent => Ci::Absent,
Is::Passed => Ci::Passed,
Is::Failed(own, inherited) => Ci::Failed(FailedChecks {
own: checks(own),
inherited: checks(inherited),
on_base: checks(inherited),
}),
}
}
}
#[derive(Default)]
struct Script {
heads: VecDeque<&'static str>,
base_commits: VecDeque<&'static str>,
base_conflicts: VecDeque<bool>,
new_commit_conflicts: VecDeque<bool>,
tree_matches: VecDeque<Result<bool, &'static str>>,
merge_completion_error: Option<&'static str>,
base_moved: VecDeque<bool>,
mergeable: VecDeque<bool>,
new_commits: VecDeque<ForeignCommits>,
assessments: VecDeque<Is>,
assessment_errors: VecDeque<Option<&'static str>>,
base_fixes: VecDeque<Option<&'static str>>,
merges: VecDeque<Option<&'static str>>,
merge_gate_error: Option<&'static str>,
base_merge_error: Option<&'static str>,
assessment_error: Option<&'static str>,
repair_error: Option<&'static str>,
sees_no_inherited_failures: bool,
interrupted: bool,
}
struct Scripted {
script: Script,
did: Vec<Did>,
stages: Vec<Stage>,
}
fn sticky(answers: &mut VecDeque<&'static str>, what: &str) -> String {
let answer = if answers.len() > 1 {
answers.pop_front()
} else {
answers.front().copied()
};
answer
.unwrap_or_else(|| panic!("no {what} scripted"))
.to_string()
}
fn names(checks: &[Check]) -> Vec<String> {
checks.iter().map(|check| check.name.clone()).collect()
}
impl Outside for Scripted {
type Pending = &'static str;
fn stage(&mut self, stage: Stage) {
self.stages.push(stage);
}
fn merge_base_branch(&mut self) -> Result<Merge<&'static str>> {
self.did.push(Did::MergeBase);
if let Some(error) = self.script.base_merge_error {
bail!(error);
}
let conflicted = self.script.base_conflicts.pop_front().unwrap_or(false);
if conflicted {
return Ok(Merge::Conflicted("origin/main"));
}
let commit = if self.script.base_commits.is_empty() {
"b1".to_string()
} else {
sticky(&mut self.script.base_commits, "Base branch commit")
};
Ok(Merge::Clean { commit })
}
fn merge_new_commits(&mut self, observed: ForeignCommits) -> Result<Merge<&'static str>> {
self.did
.push(Did::MergeNewCommits(observed.origin_head().to_string()));
let conflicted = self
.script
.new_commit_conflicts
.pop_front()
.unwrap_or(false);
Ok(if conflicted {
Merge::Conflicted("origin/issue-7")
} else {
Merge::Clean {
commit: observed.origin_head().to_string(),
}
})
}
fn ensure_merged(&mut self, pending: &&'static str) -> Result<()> {
self.did.push(Did::CheckMerged(pending));
match self.script.merge_completion_error {
Some(error) => bail!(error),
None => Ok(()),
}
}
fn push(&mut self) -> Result<()> {
self.did.push(Did::Push);
Ok(())
}
fn head(&mut self) -> Result<String> {
Ok(sticky(&mut self.script.heads, "head"))
}
fn same_tree_as(&mut self, _: &str) -> Result<bool> {
self.script
.tree_matches
.pop_front()
.unwrap_or(Ok(false))
.map_err(|error| anyhow!(error))
}
fn base_branch_moved(&mut self) -> Result<bool> {
Ok(self.script.base_moved.pop_front().unwrap_or(false))
}
fn new_commits_on_origin(&mut self) -> Result<ForeignCommits> {
Ok(self
.script
.new_commits
.pop_front()
.unwrap_or_else(|| ForeignCommits::fixture("h1", &[])))
}
fn assess_ci(&mut self, head: &str, base_commit: Option<&str>) -> Result<Ci> {
assert_eq!(self.stages.last(), Some(&Stage::CiWatch));
self.did
.push(Did::Assess(head.to_string(), base_commit.map(String::from)));
if let Some(error) = self
.script
.assessment_errors
.pop_front()
.flatten()
.or(self.script.assessment_error)
{
bail!(error);
}
Ok(self
.script
.assessments
.pop_front()
.expect("no assessment scripted")
.ci())
}
fn ci_fix_completed(&mut self, head: String, failed: FailedChecks) {
self.did.push(Did::CiFixCompleted(
head,
names(&failed.own),
names(&failed.inherited),
));
}
fn repair(&mut self, kind: Kind, repair: Repair) -> Result<()> {
let started = match repair {
Repair::Conflict(upstream) => Started::Conflict(upstream),
Repair::CiFix(failed) => {
Started::CiFix(names(&failed.own), names(&failed.inherited))
}
Repair::Review { from } => Started::Review(from.to_string()),
};
self.did.push(Did::Repair(kind, started));
match self.script.repair_error {
Some(error) => bail!(error),
None => Ok(()),
}
}
fn sees_inherited_failures(&mut self) -> bool {
!self.script.sees_no_inherited_failures
}
fn base_fix(&mut self, base_commit: &str, failed: &FailedChecks) -> Result<()> {
self.did.push(Did::BaseFix(
base_commit.to_string(),
names(&failed.inherited),
));
match self.script.base_fixes.pop_front().flatten() {
Some(cause) => Err(anyhow!(cause)),
None => Ok(()),
}
}
fn pr_mergeable(&mut self) -> Result<bool> {
self.did.push(Did::ReadMergeable);
Ok(self.script.mergeable.pop_front().unwrap_or(true))
}
fn ensure_pr_ready_and_mergeable(&mut self) -> Result<()> {
self.did.push(Did::CheckPr);
Ok(())
}
fn merge(&mut self, head: &str) -> Result<MergeAttempt> {
if let Some(error) = self.script.merge_gate_error {
bail!(error);
}
self.did.push(Did::Merge(head.to_string()));
match self.script.merges.pop_front().expect("no merge scripted") {
Some(error) => Ok(MergeAttempt::Refused(anyhow!(error))),
None => Ok(MergeAttempt::Merged),
}
}
fn interrupt_requested(&mut self) -> bool {
self.script.interrupted
}
fn progress(&mut self, line: String) {
self.did.push(Did::Line(line));
}
}
fn deliver(goal: Goal, script: Script) -> (Result<()>, Vec<Did>) {
let (delivered, did, _) = deliver_with_stages(goal, script);
(delivered, did)
}
fn deliver_with_stages(goal: Goal, script: Script) -> (Result<()>, Vec<Did>, Vec<Stage>) {
let mut outside = Scripted {
script,
did: Vec::new(),
stages: Vec::new(),
};
let delivered = take_to_goal(&mut outside, "main", goal);
(delivered, outside.did, outside.stages)
}
fn failed_in(goal: Goal, script: Script) -> (Stage, String) {
let (delivered, _, stages) = deliver_with_stages(goal, script);
let stage = *stages.last().expect("no stage entered");
(stage, cause(delivered))
}
fn script<T>(answers: impl IntoIterator<Item = T>) -> VecDeque<T> {
answers.into_iter().collect()
}
fn line(line: &str) -> Did {
Did::Line(line.to_string())
}
fn assessment(head: &str) -> Did {
Did::Assess(head.to_string(), Some("b1".to_string()))
}
fn repair(n: usize, started: Started) -> Did {
Did::Repair(Kind::Repair(n), started)
}
fn ci_fix(own: &[&str], inherited: &[&str]) -> Started {
let names = |checks: &[&str]| checks.iter().map(|name| name.to_string()).collect();
Started::CiFix(names(own), names(inherited))
}
fn round(head: &str) -> [Did; 3] {
[Did::MergeBase, Did::Push, assessment(head)]
}
fn merge(head: &str) -> [Did; 3] {
[
Did::CheckPr,
line("merging the PR into main"),
Did::Merge(head.to_string()),
]
}
fn lines(did: &[Did]) -> Vec<&str> {
did.iter()
.filter_map(|did| match did {
Did::Line(line) => Some(line.as_str()),
_ => None,
})
.collect()
}
fn completed(head: &str, own: &[&str], inherited: &[&str]) -> Did {
Did::CiFixCompleted(
head.to_string(),
own.iter().map(|name| name.to_string()).collect(),
inherited.iter().map(|name| name.to_string()).collect(),
)
}
fn cause(delivered: Result<()>) -> String {
format!("{:#}", delivered.unwrap_err())
}
#[test]
fn green_or_absent_ci_with_nothing_moved_reaches_the_goal() {
for is in [Is::Passed, Is::Absent] {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1"]),
assessments: script([is]),
merges: script([None]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(did, [round("h1").as_slice(), &merge("h1")].concat());
}
}
#[test]
fn a_run_left_ready_for_review_never_merges_or_takes_in_foreign_commits() {
let (delivered, did) = deliver(
Goal::ReadyForReview,
Script {
heads: script(["h1"]),
new_commits: script([
ForeignCommits::fixture("h1", &["f1"]),
ForeignCommits::fixture("h1", &["f1"]),
]),
assessments: script([Is::Passed]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(did, [round("h1").as_slice(), &[Did::CheckPr]].concat());
}
#[test]
fn a_conflict_with_the_base_branch_is_handed_to_a_conflict_repair_then_round_again() {
let (delivered, did) = deliver(
Goal::ReadyForReview,
Script {
heads: script(["h1"]),
base_conflicts: script([true]),
assessments: script([Is::Passed]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
&[
Did::MergeBase,
line("conflict; starting Repair 1 of 5"),
repair(1, Started::Conflict(Upstream::BaseBranch)),
Did::CheckMerged("origin/main"),
][..],
&round("h1"),
&[Did::CheckPr],
]
.concat()
);
}
#[test]
fn red_ci_is_handed_to_a_ci_fix_repair_with_only_its_own_failures_and_the_new_head_watched() {
let (delivered, did) = deliver(
Goal::ReadyForReview,
Script {
heads: script(["h1", "h2"]),
assessments: script([Is::Failed(&["test"], &["lint"]), Is::Passed]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
&round("h1")[..],
&[
line("Inherited failures (also failing on main at b1): lint"),
line("CI red; starting Repair 1 of 5"),
repair(1, ci_fix(&["test"], &["lint"])),
completed("h1", &["test"], &["lint"]),
],
&round("h2"),
&[Did::CheckPr],
]
.concat()
);
}
#[test]
fn assessment_after_a_ci_fix_waits_for_a_successful_base_merge_despite_conflict_repairs() {
let (delivered, did) = deliver(
Goal::ReadyForReview,
Script {
heads: script(["full-head-before-repair", "full-head-after-conflict"]),
base_commits: script(["b1", "selected-after-conflict"]),
base_conflicts: script([false, true, false]),
assessments: script([Is::Failed(&["test"], &[]), Is::Passed]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
round("full-head-before-repair").to_vec(),
vec![
line("CI red; starting Repair 1 of 5"),
repair(1, ci_fix(&["test"], &[])),
completed("full-head-before-repair", &["test"], &[]),
Did::MergeBase,
line("conflict; starting Repair 2 of 5"),
repair(2, Started::Conflict(Upstream::BaseBranch)),
Did::CheckMerged("origin/main"),
Did::MergeBase,
Did::Push,
Did::Assess(
"full-head-after-conflict".to_string(),
Some("selected-after-conflict".to_string())
),
Did::CheckPr,
]
]
.concat()
);
}
#[test]
fn a_failed_ci_fix_never_notifies_ci_watch() {
let (delivered, did, stages) = deliver_with_stages(
Goal::ReadyForReview,
Script {
heads: script(["h1"]),
assessments: script([Is::Failed(&["test"], &[])]),
repair_error: Some("CI-fix session or PR postcheck failed"),
..Script::default()
},
);
assert_eq!(cause(delivered), "CI-fix session or PR postcheck failed");
assert_eq!(
did,
[
round("h1").to_vec(),
vec![
line("CI red; starting Repair 1 of 5"),
repair(1, ci_fix(&["test"], &[]))
]
]
.concat()
);
assert_eq!(stages.last(), Some(&Stage::RepairSession));
}
#[test]
fn a_declined_ci_fix_from_assessment_stops_in_ci_watch_without_another_repair() {
let (delivered, did, stages) = deliver_with_stages(
Goal::ReadyForReview,
Script {
heads: script(["h1"]),
assessments: script([Is::Failed(&["test"], &[])]),
assessment_errors: script([
None,
Some("CI red on h1 and the Repair found nothing to fix on the branch"),
]),
..Script::default()
},
);
assert_eq!(
cause(delivered),
"CI red on h1 and the Repair found nothing to fix on the branch"
);
assert_eq!(
did,
[
round("h1").to_vec(),
vec![
line("CI red; starting Repair 1 of 5"),
repair(1, ci_fix(&["test"], &[])),
completed("h1", &["test"], &[])
],
round("h1").to_vec()
]
.concat()
);
assert_eq!(stages.last(), Some(&Stage::CiWatch));
}
#[test]
fn each_assessment_uses_the_commit_returned_by_its_base_merge() {
for next_base in ["sampled-base", "rewound-base"] {
let (delivered, did) = deliver(
Goal::ReadyForReview,
Script {
heads: script(["h1"]),
base_commits: script(["sampled-base", next_base]),
assessments: script([Is::Failed(&["test"], &[]), Is::Passed]),
..Script::default()
},
);
delivered.unwrap();
let assessments: Vec<_> = did
.iter()
.filter(|did| matches!(did, Did::Assess(..)))
.cloned()
.collect();
assert_eq!(
assessments,
[
Did::Assess("h1".to_string(), Some("sampled-base".to_string())),
Did::Assess("h1".to_string(), Some(next_base.to_string()))
]
);
}
}
#[test]
fn a_refused_self_merge_reassesses_ci_before_another_repair() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1", "h1", "h2"]),
assessments: script([Is::Passed, Is::Failed(&["test"], &[]), Is::Passed]),
merges: script([Some("merge refused"), None]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
round("h1").to_vec(),
merge("h1").to_vec(),
vec![line("the merge failed: merge refused")],
round("h1").to_vec(),
vec![
line("CI red; starting Repair 1 of 5"),
repair(1, ci_fix(&["test"], &[])),
completed("h1", &["test"], &[])
],
round("h2").to_vec(),
merge("h2").to_vec()
]
.concat()
);
}
fn up_to_inherited_failures() -> Vec<Did> {
[
&round("h1")[..],
&[line(
"Inherited failures (also failing on main at b1): test",
)],
]
.concat()
}
#[test]
fn inherited_failures_only_go_round_again_as_a_base_move_if_the_base_branch_moved() {
let (delivered, did) = deliver(
Goal::ReadyForReview,
Script {
heads: script(["h1", "h2"]),
base_commits: script(["b1", "b2"]),
base_moved: script([true]),
assessments: script([Is::Failed(&[], &["test"]), Is::Passed]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
up_to_inherited_failures(),
vec![
line("origin/main moved while CI ran; merging it again"),
Did::MergeBase,
Did::Push,
Did::Assess("h2".to_string(), Some("b2".to_string())),
Did::CheckPr,
],
]
.concat()
);
}
#[test]
fn inherited_failures_only_take_the_base_fix_then_go_round_again() {
let (delivered, did) = deliver(
Goal::ReadyForReview,
Script {
heads: script(["h1"]),
assessments: script([Is::Failed(&[], &["test"]), Is::Passed]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
up_to_inherited_failures(),
vec![Did::BaseFix("b1".to_string(), vec!["test".to_string()])],
round("h1").into(),
vec![Did::CheckPr],
]
.concat()
);
}
#[test]
fn a_failed_base_fix_is_the_runs_cause() {
let fails = "CI red on test, which also fails on main at b1; fix main first";
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1"]),
assessments: script([Is::Failed(&[], &["test"])]),
base_fixes: script([Some(fails)]),
..Script::default()
},
);
assert_eq!(cause(delivered), fails);
assert_eq!(
did,
[
up_to_inherited_failures(),
vec![Did::BaseFix("b1".to_string(), vec!["test".to_string()])],
]
.concat()
);
}
#[test]
fn a_run_that_sees_no_inherited_failures_assesses_ci_with_no_base_branch_commit() {
let (delivered, did) = deliver(
Goal::ReadyForReview,
Script {
heads: script(["h1"]),
assessments: script([Is::Passed]),
sees_no_inherited_failures: true,
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
Did::MergeBase,
Did::Push,
Did::Assess("h1".to_string(), None),
Did::CheckPr
]
);
}
#[test]
fn in_a_merge_run_the_base_branch_moving_while_ci_ran_goes_round_again_as_a_base_move() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1", "h2"]),
base_moved: script([true]),
assessments: script([Is::Passed, Is::Passed]),
merges: script([None]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
&round("h1")[..],
&[line("origin/main moved while CI ran; merging it again")],
&round("h2"),
&merge("h2"),
]
.concat()
);
}
#[test]
fn a_run_left_ready_for_review_ends_on_green_ci_when_the_moved_base_branch_merges_cleanly() {
for is in [Is::Passed, Is::Absent] {
let (delivered, did) = deliver(
Goal::ReadyForReview,
Script {
heads: script(["h1234567890", "h2"]),
base_moved: script([true]),
assessments: script([is]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
&[Did::MergeBase, Did::Push, assessment("h1234567890")][..],
&[
Did::ReadMergeable,
line(
"origin/main moved while CI ran; leaving the PR for review on \
h123456, which merges cleanly"
),
Did::CheckPr,
],
]
.concat()
);
}
}
#[test]
fn a_run_left_ready_for_review_goes_round_again_as_a_base_move_if_the_pr_does_not_merge() {
let (delivered, did) = deliver(
Goal::ReadyForReview,
Script {
heads: script(["h1", "h2"]),
base_moved: script([true]),
base_conflicts: script([false, true]),
mergeable: script([false]),
assessments: script([Is::Passed, Is::Passed]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
&round("h1")[..],
&[
Did::ReadMergeable,
line("origin/main moved while CI ran; merging it again"),
Did::MergeBase,
line("conflict; starting Repair 1 of 5"),
repair(1, Started::Conflict(Upstream::BaseBranch)),
Did::CheckMerged("origin/main"),
],
&round("h2"),
&[Did::CheckPr],
]
.concat()
);
}
fn foreign_commit(sha: &str, own_head: &str, n: usize) -> Vec<Did> {
vec![
line("origin/issue-7 has new commits; merging them in"),
line(&format!("merging new commit {sha} from origin/issue-7")),
Did::MergeNewCommits(sha.to_string()),
line(&format!(
"new commits on origin/issue-7 to review; starting Repair {n} of 5"
)),
repair(n, Started::Review(own_head.to_string())),
]
}
#[test]
fn tree_identical_foreign_batches_leave_repair_slots_for_changed_trees() {
let (delivered, did) =
deliver(
Goal::Merged,
Script {
heads: script(["h5"]),
new_commits: script((1..=5).map(|n| {
ForeignCommits::fixture(&format!("h{}", n - 1), &[&format!("f{n}")])
})),
tree_matches: script([Ok(true), Ok(true), Ok(true), Ok(true), Ok(false)]),
assessments: script([Is::Passed]),
merges: script([None]),
..Script::default()
},
);
delivered.unwrap();
let repairs: Vec<_> = did
.iter()
.filter(|did| matches!(did, Did::Repair(..)))
.collect();
assert_eq!(repairs, [&repair(1, Started::Review("h4".to_string()))]);
assert_eq!(
did.iter()
.filter(|did| matches!(did, Did::MergeNewCommits(..)))
.count(),
5
);
}
#[test]
fn tree_identical_foreign_batches_still_spend_the_upstream_move_budget() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
new_commits: script(
(1..=6).map(|n| ForeignCommits::fixture(&format!("h{n}"), &[&format!("f{n}")])),
),
tree_matches: script([Ok(true); 5]),
..Script::default()
},
);
assert_eq!(
cause(delivered),
"origin/issue-7 kept moving: merged it again 5 times"
);
assert_eq!(
did.iter()
.filter(|did| matches!(did, Did::MergeNewCommits(..)))
.count(),
5
);
assert!(!did.iter().any(|did| matches!(did, Did::Repair(..))));
}
#[test]
fn a_failed_tree_comparison_cannot_authorize_self_merge() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
new_commits: script([ForeignCommits::fixture("h1", &["f1"])]),
tree_matches: script([Err("cannot compare trees")]),
..Script::default()
},
);
assert_eq!(cause(delivered), "cannot compare trees");
assert!(
!did.iter()
.any(|did| matches!(did, Did::Merge(..) | Did::Assess(..) | Did::Repair(..)))
);
}
#[test]
fn an_identical_tree_does_not_excuse_an_unfinished_foreign_merge() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
new_commits: script([ForeignCommits::fixture("h1", &["f1"])]),
new_commit_conflicts: script([true]),
merge_completion_error: Some("merge is still in progress"),
tree_matches: script([Ok(true)]),
..Script::default()
},
);
assert_eq!(cause(delivered), "merge is still in progress");
assert_eq!(did.last(), Some(&Did::CheckMerged("origin/issue-7")));
assert!(did.contains(&repair(1, Started::Conflict(Upstream::IssueBranch))));
}
#[test]
fn a_merge_run_merges_foreign_commits_in_and_reviews_them_while_origin_has_more() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h3"]),
new_commits: script([
ForeignCommits::fixture("h1", &["f1", "f2"]),
ForeignCommits::fixture("h2", &["f3"]),
]),
assessments: script([Is::Passed]),
merges: script([None]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
vec![
line("origin/issue-7 has new commits; merging them in"),
line("merging new commit f1 from origin/issue-7"),
line("merging new commit f2 from origin/issue-7"),
Did::MergeNewCommits("f2".to_string()),
line("new commits on origin/issue-7 to review; starting Repair 1 of 5"),
repair(1, Started::Review("h1".to_string())),
],
foreign_commit("f3", "h2", 2),
round("h3").into(),
merge("h3").into(),
]
.concat()
);
}
#[test]
fn tree_identical_foreign_commits_reconcile_without_a_review_repair() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["f1"]),
new_commits: script([ForeignCommits::fixture("own", &["f1"])]),
tree_matches: script([Ok(true)]),
assessments: script([Is::Passed]),
merges: script([None]),
..Script::default()
},
);
delivered.unwrap();
assert!(!did.iter().any(|did| matches!(did, Did::Repair(..))));
assert!(did.contains(&Did::MergeNewCommits("f1".to_string())));
assert!(did.contains(&Did::Push));
assert!(did.contains(&Did::Merge("f1".to_string())));
}
#[test]
fn an_empty_foreign_batch_between_changed_batches_spends_no_repair_slot() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["f3"]),
new_commits: script([
ForeignCommits::fixture("own", &["f1"]),
ForeignCommits::fixture("reviewed", &["f2"]),
ForeignCommits::fixture("f2", &["f3"]),
]),
tree_matches: script([Ok(false), Ok(true), Ok(false)]),
assessments: script([Is::Passed]),
merges: script([None]),
..Script::default()
},
);
delivered.unwrap();
let repairs: Vec<_> = did
.iter()
.filter(|did| matches!(did, Did::Repair(..)))
.collect();
assert_eq!(
repairs,
[
&repair(1, Started::Review("own".to_string())),
&repair(2, Started::Review("f2".to_string())),
]
);
assert!(did.contains(&Did::MergeNewCommits("f2".to_string())));
assert!(did.contains(&Did::Merge("f3".to_string())));
}
#[test]
fn an_identical_tree_after_conflict_repair_needs_no_review_repair() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["resolved"]),
new_commits: script([ForeignCommits::fixture("own", &["f1"])]),
new_commit_conflicts: script([true]),
tree_matches: script([Ok(true)]),
assessments: script([Is::Passed]),
merges: script([None]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did.iter()
.filter(|did| matches!(did, Did::Repair(..)))
.collect::<Vec<_>>(),
[&repair(1, Started::Conflict(Upstream::IssueBranch))]
);
assert!(did.contains(&Did::CheckMerged("origin/issue-7")));
assert!(did.contains(&Did::Merge("resolved".to_string())));
}
#[test]
fn an_identical_foreign_tree_can_reconcile_after_all_repairs_are_spent() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1", "h2", "h3", "h4", "h5", "h6"]),
assessments: script(
(0..5)
.map(|_| Is::Failed(&["test"], &[]))
.chain([Is::Passed]),
),
new_commits: script(
(0..5)
.map(|_| ForeignCommits::fixture("own", &[]))
.chain([ForeignCommits::fixture("h6", &["f1"])]),
),
tree_matches: script([Ok(true)]),
merges: script([None]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did.iter()
.filter(|did| matches!(did, Did::Repair(..)))
.count(),
5
);
assert!(
!did.iter()
.any(|did| matches!(did, Did::Repair(_, Started::Review(_))))
);
assert!(did.contains(&Did::MergeNewCommits("f1".to_string())));
}
#[test]
fn foreign_commits_that_arrive_while_ci_ran_go_round_again() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1", "h2"]),
new_commits: script([
ForeignCommits::fixture("h1", &[]),
ForeignCommits::fixture("h1", &["f1"]),
ForeignCommits::fixture("h1", &["f1"]),
]),
assessments: script([Is::Passed, Is::Passed]),
merges: script([None]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
round("h1").into(),
foreign_commit("f1", "h1", 1),
round("h2").into(),
merge("h2").into(),
]
.concat()
);
}
#[test]
fn a_conflict_with_foreign_commits_is_handed_to_a_conflict_repair_then_reviewed() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h2"]),
new_commits: script([ForeignCommits::fixture("h1", &["f1"])]),
new_commit_conflicts: script([true]),
assessments: script([Is::Passed]),
merges: script([None]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
vec![
line("origin/issue-7 has new commits; merging them in"),
line("merging new commit f1 from origin/issue-7"),
Did::MergeNewCommits("f1".to_string()),
line("conflict with new commits on origin/issue-7; starting Repair 1 of 5"),
repair(1, Started::Conflict(Upstream::IssueBranch)),
Did::CheckMerged("origin/issue-7"),
line("new commits on origin/issue-7 to review; starting Repair 2 of 5"),
repair(2, Started::Review("h1".to_string())),
],
round("h2").into(),
merge("h2").into(),
]
.concat()
);
}
#[test]
fn every_kind_of_repair_shares_one_cap_and_the_one_too_many_fails_with_its_cause() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1", "h2", "h3", "h4"]),
new_commits: script([ForeignCommits::fixture("h0", &["f1"])]),
base_conflicts: script([true]),
assessments: script([
Is::Failed(&["test"], &[]),
Is::Failed(&["test"], &[]),
Is::Failed(&["test"], &[]),
Is::Failed(&["test"], &[]),
]),
..Script::default()
},
);
assert_eq!(cause(delivered), "repairs exhausted: CI red");
assert_eq!(
lines(&did),
[
"origin/issue-7 has new commits; merging them in",
"merging new commit f1 from origin/issue-7",
"new commits on origin/issue-7 to review; starting Repair 1 of 5",
"conflict; starting Repair 2 of 5",
"CI red; starting Repair 3 of 5",
"CI red; starting Repair 4 of 5",
"CI red; starting Repair 5 of 5",
]
);
assert_eq!(did.last(), Some(&assessment("h4")));
}
#[test]
fn base_moves_and_foreign_commits_share_one_upstream_move_budget() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1"]),
new_commits: script([ForeignCommits::fixture("h0", &["f1"])]),
base_moved: script([true; 5]),
assessments: script((0..5).map(|_| Is::Passed)),
..Script::default()
},
);
assert_eq!(
cause(delivered),
"origin/main kept moving: merged it again 5 times"
);
assert_eq!(
lines(&did),
[
"origin/issue-7 has new commits; merging them in",
"merging new commit f1 from origin/issue-7",
"new commits on origin/issue-7 to review; starting Repair 1 of 5",
"origin/main moved while CI ran; merging it again",
"origin/main moved while CI ran; merging it again",
"origin/main moved while CI ran; merging it again",
"origin/main moved while CI ran; merging it again",
]
);
}
#[test]
fn a_sixth_foreign_batch_is_refused_before_its_merge() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
new_commits: script(
(1..=6).map(|n| ForeignCommits::fixture(&format!("h{n}"), &[&format!("f{n}")])),
),
..Script::default()
},
);
assert_eq!(
cause(delivered),
"origin/issue-7 kept moving: merged it again 5 times"
);
let merged: Vec<_> = did
.iter()
.filter_map(|did| match did {
Did::MergeNewCommits(head) => Some(head.as_str()),
_ => None,
})
.collect();
assert_eq!(merged, ["f1", "f2", "f3", "f4", "f5"]);
}
#[test]
fn review_budget_refusal_still_follows_the_allowed_foreign_merge() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1", "h2", "h3", "h4", "h5"]),
assessments: script((0..5).map(|_| Is::Failed(&["test"], &[]))),
new_commits: script(
(0..5)
.map(|_| ForeignCommits::fixture("own", &[]))
.chain([ForeignCommits::fixture("h6", &["f1"])]),
),
..Script::default()
},
);
assert_eq!(
cause(delivered),
"repairs exhausted: new commits on origin/issue-7 to review"
);
assert_eq!(did.last(), Some(&Did::MergeNewCommits("f1".to_string())));
assert!(
!did.iter()
.any(|did| matches!(did, Did::Repair(_, Started::Review(_))))
);
}
#[test]
fn a_terminal_pre_merge_gate_error_never_enters_another_round_or_policy_refusal() {
let (ended, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1"]),
assessments: script([Is::Passed]),
merge_gate_error: Some("PR targets develop, not main"),
..Script::default()
},
);
let error = ended.unwrap_err();
assert_eq!(error.to_string(), "PR targets develop, not main");
assert!(!error.is::<PolicyRefusal>());
assert_eq!(
did.iter()
.filter(|call| matches!(call, Did::Assess(..)))
.count(),
1
);
assert!(
!did.iter()
.any(|call| matches!(call, Did::Merge(..) | Did::Repair(..)))
);
}
#[test]
fn a_failed_merge_goes_round_again_counting_a_base_move_and_retries_on_the_new_head() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1", "h2"]),
base_moved: script([false, true, false]),
assessments: script([Is::Passed, Is::Passed]),
merges: script([Some("Base branch was modified"), None]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
did,
[
&round("h1")[..],
&merge("h1"),
&[
line("the merge failed: Base branch was modified"),
line("origin/main moved since the merge was tried; merging it again"),
],
&round("h2"),
&merge("h2"),
]
.concat()
);
}
#[test]
fn failed_merges_that_keep_moving_the_base_branch_spend_its_budget_and_are_no_policy_refusal() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1", "h2"]),
base_moved: script([false, true].repeat(6)),
assessments: script((0..6).map(|_| Is::Passed)),
merges: script((0..6).map(|_| Some("Base branch was modified"))),
..Script::default()
},
);
let error = delivered.unwrap_err();
assert!(!error.is::<PolicyRefusal>(), "{error:#}");
assert_eq!(
format!("{error:#}"),
"origin/main kept moving: merged it again 5 times"
);
assert_eq!(
did.iter()
.filter(|did| matches!(did, Did::Merge(_)))
.count(),
6
);
assert_eq!(
lines(&did)
.iter()
.filter(|line| line.starts_with("origin/main moved since the merge"))
.count(),
5
);
}
#[test]
fn a_failed_merge_whose_round_finds_nothing_to_fix_is_a_policy_refusal() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1"]),
assessments: script([Is::Passed, Is::Passed]),
merges: script([Some("refused by a ruleset")]),
..Script::default()
},
);
let error = delivered.unwrap_err();
assert!(error.is::<PolicyRefusal>(), "{error:#}");
assert_eq!(
did,
[
&round("h1")[..],
&merge("h1"),
&[line("the merge failed: refused by a ruleset")],
&round("h1"),
&[Did::CheckPr],
]
.concat()
);
}
#[test]
fn a_failed_merge_whose_round_needed_a_repair_retries_on_the_same_head() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1"]),
base_conflicts: script([false, true]),
assessments: script([Is::Passed, Is::Passed]),
merges: script([Some("not mergeable"), None]),
..Script::default()
},
);
delivered.unwrap();
assert_eq!(
lines(&did),
[
"merging the PR into main",
"the merge failed: not mergeable",
"conflict; starting Repair 1 of 5",
"merging the PR into main",
]
);
assert_eq!(did.last(), Some(&Did::Merge("h1".to_string())));
}
#[test]
fn an_interrupt_before_the_merge_fails_as_interrupted() {
let (delivered, did) = deliver(
Goal::Merged,
Script {
heads: script(["h1"]),
assessments: script([Is::Passed]),
interrupted: true,
..Script::default()
},
);
assert_eq!(cause(delivered), "interrupted");
assert_eq!(did, [round("h1").as_slice(), &[Did::CheckPr]].concat());
}
#[test]
fn repairs_of_every_kind_share_one_cap_and_the_one_too_many_names_its_cause() {
let mut budgets = Budgets::default();
for n in 1..=MAX_REPAIRS {
let cause = if n % 2 == 0 { "conflict" } else { "CI red" };
assert_eq!(
budgets.next_repair(cause).unwrap(),
(
Kind::Repair(n),
format!("{cause}; starting Repair {n} of {MAX_REPAIRS}")
)
);
}
for cause in ["new commits on origin/issue-7 to review", "conflict"] {
let error = budgets.next_repair(cause).unwrap_err();
assert_eq!(error.to_string(), format!("repairs exhausted: {cause}"));
}
assert_eq!(budgets.repairs, MAX_REPAIRS);
}
#[test]
fn upstream_moves_of_the_base_or_the_issue_branch_share_one_budget() {
let mut budgets = Budgets::default();
for _ in 1..MAX_UPSTREAM_MOVES {
assert_eq!(
budgets.count_base_move("main", "while CI ran").unwrap(),
"origin/main moved while CI ran; merging it again"
);
}
assert_eq!(
budgets.count_foreign_commits("origin/issue-7").unwrap(),
"origin/issue-7 has new commits; merging them in"
);
let error = budgets.count_base_move("main", "while CI ran").unwrap_err();
assert_eq!(
error.to_string(),
format!("origin/main kept moving: merged it again {MAX_UPSTREAM_MOVES} times")
);
let error = budgets.count_foreign_commits("origin/issue-7").unwrap_err();
assert_eq!(
error.to_string(),
format!("origin/issue-7 kept moving: merged it again {MAX_UPSTREAM_MOVES} times")
);
}
#[test]
fn the_repair_cap_and_the_upstream_move_budget_are_spent_apart() {
let mut budgets = Budgets::default();
for _ in 0..MAX_REPAIRS {
budgets.next_repair("CI red").unwrap();
}
for _ in 0..MAX_UPSTREAM_MOVES {
budgets.count_base_move("main", "while CI ran").unwrap();
}
assert_eq!(
budgets,
Budgets {
repairs: MAX_REPAIRS,
upstream_moves: MAX_UPSTREAM_MOVES
}
);
}
#[test]
fn a_failed_merge_of_the_base_branch_stops_in_a_repair_loop_round() {
assert_eq!(
failed_in(
Goal::ReadyForReview,
Script {
base_merge_error: Some("could not fetch origin/main"),
..Script::default()
},
),
(
Stage::RepairLoopRound,
"could not fetch origin/main".to_string()
)
);
}
#[test]
fn a_run_left_ready_for_review_reads_mergeability_in_readiness_and_counts_an_unmergeable_pr_in_a_round()
{
let (delivered, _, stages) = deliver_with_stages(
Goal::ReadyForReview,
Script {
heads: script(["h1"]),
base_moved: script([true; MAX_UPSTREAM_MOVES + 1]),
mergeable: script([false; MAX_UPSTREAM_MOVES + 1]),
assessments: script((0..=MAX_UPSTREAM_MOVES).map(|_| Is::Passed)),
..Script::default()
},
);
assert_eq!(
cause(delivered),
"origin/main kept moving: merged it again 5 times"
);
assert_eq!(
stages[stages.len() - 3..],
[
Stage::CiWatch,
Stage::PullRequestReadiness,
Stage::RepairLoopRound
]
);
}
#[test]
fn a_failed_ci_watch_stops_in_the_ci_watch() {
assert_eq!(
failed_in(
Goal::ReadyForReview,
Script {
heads: script(["h1"]),
assessment_error: Some("gh run list failed"),
..Script::default()
},
),
(Stage::CiWatch, "gh run list failed".to_string())
);
}
#[test]
fn a_failed_repair_session_stops_in_the_repair_session() {
assert_eq!(
failed_in(
Goal::ReadyForReview,
Script {
base_conflicts: script([true]),
repair_error: Some("the repair-1 session failed"),
..Script::default()
},
),
(
Stage::RepairSession,
"the repair-1 session failed".to_string()
)
);
}
#[test]
fn exhausted_repairs_stop_in_a_repair_loop_round_without_a_session() {
let (delivered, _, stages) = deliver_with_stages(
Goal::ReadyForReview,
Script {
base_conflicts: script([true; MAX_REPAIRS + 1]),
..Script::default()
},
);
assert!(cause(delivered).starts_with("repairs exhausted"));
assert_eq!(stages.last(), Some(&Stage::RepairLoopRound));
assert_eq!(
stages
.iter()
.filter(|stage| **stage == Stage::RepairSession)
.count(),
MAX_REPAIRS
);
}
#[test]
fn a_refused_self_merge_stops_in_the_self_merge() {
let (delivered, _, stages) = deliver_with_stages(
Goal::Merged,
Script {
heads: script(["h1"]),
assessments: script([Is::Passed, Is::Passed]),
merges: script([Some("refused by a ruleset")]),
..Script::default()
},
);
assert!(delivered.unwrap_err().is::<PolicyRefusal>());
assert_eq!(
stages,
[
Stage::RepairLoopRound,
Stage::Push,
Stage::CiWatch,
Stage::SelfMerge,
Stage::SelfMerge,
Stage::RepairLoopRound,
Stage::RepairLoopRound,
Stage::Push,
Stage::CiWatch,
Stage::SelfMerge,
]
);
}
#[test]
fn a_base_fix_and_a_ready_pull_request_enter_their_stages() {
let (delivered, _, stages) = deliver_with_stages(
Goal::ReadyForReview,
Script {
heads: script(["h1"]),
assessments: script([Is::Failed(&[], &["lint"]), Is::Passed]),
base_fixes: script([None]),
..Script::default()
},
);
assert!(delivered.is_ok());
assert_eq!(
stages,
[
Stage::RepairLoopRound,
Stage::Push,
Stage::CiWatch,
Stage::BaseFix,
Stage::RepairLoopRound,
Stage::Push,
Stage::CiWatch,
Stage::PullRequestReadiness,
]
);
}
}