diff --git a/GLOSSARY.md b/GLOSSARY.md
index 37a1c3c1..96dc7a3e 100644
--- a/GLOSSARY.md
+++ b/GLOSSARY.md
@@ -218,7 +218,7 @@ What a **Run**, or a **Spec run** for its **Spec PR**, does from its worktree on
If a Delivery gate observes that its pull request has already merged into the expected Base branch, and its merged head includes all local work (the exact local head, with no uncommitted changes), the Run ends as merged, even when it originally asked only for ready for review. Delivery stops further publication, PR edits and Self-merge work. A merged PR that leaves local work out still fails and retains that work; a merged state alone never establishes completion.
**Repair loop**:
-The rounds a **Delivery** goes through until the pull request's head is mergeable with the **Base branch** and its CI is green or absent. Each round merges the Base branch in (and, in a **Merge run**, any **Foreign commits**), pushes, watches CI, and starts a **Repair** for a conflict or for the branch's own red checks. Repairs and upstream moves have fixed budgets.
+The rounds a **Delivery** goes through until the pull request's head is mergeable with the **Base branch** and its CI is green or absent. Each round merges the Base branch in (and, in a **Merge run**, any **Foreign commits**), pushes, watches CI, and starts a **Repair** for a conflict or for the branch's own red checks. When the Base branch moved while green or absent CI ran, a Merge run goes round again to merge it in. A Delivery that leaves the pull request ready for review stops on that head if GitHub reports the pull request mergeable, and the move is not counted; otherwise it goes round again. Repairs and upstream moves have fixed budgets.
**Foreign commit**:
A commit that appears on the **Issue branch** during a **Run** and was made neither by one of that Run's sessions nor by thirdshift itself. Commits already on the branch when a **Continuation** starts are not Foreign commits. A **Merge run** merges one only after a **Repair** has reviewed its resulting tree, unless that tree is identical to the Run's own head before reconciliation and is already covered by its existing review. A tree-identical batch still counts as an upstream move, but uses no Review Repair.
diff --git a/site/how-it-works/index.html b/site/how-it-works/index.html
index a384f2d5..2a199856 100644
--- a/site/how-it-works/index.html
+++ b/site/how-it-works/index.html
@@ -126,7 +126,7 @@
Base branch moved during CI?A Self-merge reconciles the new upstream state. A pull request left for review stops on green CI if it still merges cleanly.Otherwise return to merging the Base branch ↑
Repair and upstream-move budgets keep the loop bounded. If a CI-fix Repair leaves the same failing head, that head gets one Check re-run; if its own checks stay red, the Run fails rather than spending more Repairs on unchanged code.
diff --git a/src/delivery.rs b/src/delivery.rs
index 2cbbe4f2..b4dda75b 100644
--- a/src/delivery.rs
+++ b/src/delivery.rs
@@ -743,6 +743,10 @@ impl repair_loop::Outside for RunOutside<'_> {
.fix(outside, self.pr_url, self.base, base_commit, failed)
}
+ fn pr_mergeable(&mut self) -> Result {
+ self.pull_request.is_mergeable()
+ }
+
fn ensure_pr_ready_and_mergeable(&mut self) -> Result<()> {
self.pull_request.ensure_ready_and_mergeable()
}
diff --git a/src/delivery/repair_loop.rs b/src/delivery/repair_loop.rs
index dc4e4cf4..11adc0f3 100644
--- a/src/delivery/repair_loop.rs
+++ b/src/delivery/repair_loop.rs
@@ -92,6 +92,9 @@ pub(super) trait Outside {
/// go round again.
fn base_fix(&mut self, base_commit: &str, failed: &FailedChecks) -> Result<()>;
+ /// Whether GitHub reports the open, ready pull request mergeable with the
+ /// Base branch. Mergeability it has not worked out in time is not.
+ fn pr_mergeable(&mut self) -> Result;
/// Fail unless the pull request is open, ready for review and mergeable.
fn ensure_pr_ready_and_mergeable(&mut self) -> Result<()>;
/// Merge the pull request at `head`.
@@ -162,7 +165,9 @@ const MAX_REPAIRS: usize = 5;
/// CI ran or since a merge was tried, or, in a Merge run, because Foreign
/// commits arrived, whether or not the merge that follows needs a Repair. A
/// clean merge of the Base branch uses no Repair, so without this a busy Base
-/// branch could keep a Delivery going forever.
+/// branch could keep a Delivery going forever. A Base branch that moved while
+/// green or absent CI ran is not counted for a Delivery left ready for review
+/// whose pull request still merges cleanly, since that Delivery stops there.
const MAX_UPSTREAM_MOVES: usize = 5;
/// What a Delivery has spent of its Repair and upstream-move budgets,
@@ -288,6 +293,13 @@ impl RepairLoop<'_, O> {
self.outside.repair(kind, repair)
}
+ /// Whether GitHub reports the pull request mergeable, read in the
+ /// readiness stage.
+ fn pr_merges_cleanly(&mut self) -> Result {
+ self.outside.stage(Stage::PullRequestReadiness);
+ self.outside.pr_mergeable()
+ }
+
/// Count a round taken because the Base branch moved `when`.
fn count_base_move(&mut self, when: &str) -> Result<()> {
let line = self.budgets.count_base_move(self.base, when)?;
@@ -299,8 +311,11 @@ impl RepairLoop<'_, O> {
/// Base branch (never rebase), push, and watch CI on the head commit,
/// starting a Repair session for a conflict or red
/// CI and then going round again, since the Base branch may have moved
- /// meanwhile. Green or absent CI also goes round again if the Base branch
- /// moved while CI ran, or, in a Merge run, if Foreign commits arrived.
+ /// meanwhile. Green or absent CI also goes round again, in a Merge run, if
+ /// the Base branch moved while CI ran or Foreign commits arrived. Left
+ /// ready for review, it returns that head without counting the move if
+ /// GitHub reports the pull request mergeable, and otherwise goes round
+ /// again.
/// A red check that also fails on the Base branch commit the head last
/// merged in is an Inherited failure, which no Repair is started for: the
/// CI-fix Repair is given the branch's own failures, and when there are
@@ -348,6 +363,20 @@ impl RepairLoop<'_, O> {
match ci {
Ci::Absent | Ci::Passed => {
if self.outside.base_branch_moved()? {
+ // Green CI on a head that merges cleanly is enough
+ // for review; only a Self-merge needs the moved Base
+ // branch merged in and watched.
+ 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()
@@ -463,6 +492,8 @@ mod tests {
/// Took the Base fix for these Inherited failures, at this Base
/// branch commit.
BaseFix(String, Vec),
+ /// Read whether GitHub reports the PR mergeable.
+ ReadMergeable,
/// Checked the PR is open, ready and mergeable.
CheckPr,
/// Merged the PR at this head.
@@ -519,8 +550,8 @@ mod tests {
/// What the outside world answers, call by call. A head or Base branch
/// commit stays the last one given; a merge is clean, the Base branch
- /// unmoved, origin without new commits and a Base fix returns, unless
- /// scripted otherwise.
+ /// unmoved, the PR mergeable, origin without new commits and a Base fix
+ /// returns, unless scripted otherwise.
#[derive(Default)]
struct Script {
heads: VecDeque<&'static str>,
@@ -532,6 +563,9 @@ mod tests {
tree_matches: VecDeque>,
merge_completion_error: Option<&'static str>,
base_moved: VecDeque,
+ /// For each read of the PR's mergeability, whether GitHub reports it
+ /// mergeable.
+ mergeable: VecDeque,
new_commits: VecDeque,
watches: VecDeque,
reruns: VecDeque