Repository navigation
Fix: A superseded merge exception outlives the attempt that raised it #504
Description
Summary
_last_merge_exception records the last exception a merge attempt raised, but nothing clears it when a later attempt in the same retry loop gets a non-raising answer from the API. A consumer reading it afterwards can therefore attribute the run's outcome to an exception that a subsequent attempt superseded.
Raised from review of #503, where the classification it feeds was being introduced on a new path. Declined there deliberately: the defect is in _retry.py's bookkeeping and is read by four unrelated consumers, so correcting it inside a conflict-reporting change would have altered behaviour well outside that change's remit.
The sequence
_merge_pr_with_retry (src/dependamerge/merge_manager/_retry.py:53-99) loops over attempts. Two branches end an iteration:
_dispatch_mergeraises →_handle_merge_exceptionstores the exception (_retry.py:150-153) and returnsCONTINUEorSTOP._dispatch_mergereturnsFalse(GitHub answeredmerged: false) →_retry_after_false_mergereturnsRETRYorSTOP. Nothing is stored, and nothing is cleared.
So a run that goes
attempt 0: 502 Bad Gateway -> exception stored, CONTINUE
attempt 1: HTTP 200, merged=false -> nothing stored, STOP
returns False with the 502 still in _last_merge_exception, even though the attempt that actually decided the outcome was a clean refusal by GitHub.
The same applies to an exception followed by the loop simply exhausting its attempts.
Who reads it
The stored exception is consulted well after the loop returns:
| Consumer | Uses it to |
|---|---|
_failure.py:159 _get_failure_summary |
choose the reported failure reason, and whether it is withdrawable |
_conflict_wait.py _rebased_merge_was_refused |
classify a post-rebase merge failure (added in #503) |
_approval.py:115 |
detect a missing-approval rejection and re-approve |
_single_pr_merge.py:248 |
detect a pending-workflows rejection and wait |
_required_workflows.py:101 |
same, guarded by _last_merge_exception_head |
Only the last of those guards against staleness, and it guards a different staleness — a force-push moving the head SHA — rather than a superseded attempt on the same head.
Consequence
Bounded, and currently in the harmless direction. On the _conflict_wait.py path the reported text is fixed prose, so a stale 502 hides nothing; it makes a genuine refusal read as non-withdrawable, and the failure stands where it could have been reported as re-runnable. The next run merges the PR. _confirm_failure's other two corrections — merged, and closed-unmerged — do not consult the flag, so nothing that actually landed is misreported.
_get_failure_summary is the consumer with more to lose: it would surface the 502's text as the reason for a failure that GitHub refused on the PR's state, which is a less accurate diagnosis than the one available.
Suggested approach
Have the loop record what ended it, rather than leaving consumers to infer that from a field only one branch writes. Either:
- clear
_last_merge_exception/_last_merge_exception_headwhen a later attempt receives a non-raising API response, so the field means "the exception from the final attempt" as its readers assume; or - return the terminal outcome from
_merge_pr_with_retryalongside the boolean, so no inference from run-scoped state is needed.
The first is smaller and fixes every consumer at once. The second is closer to what the callers actually want, and overlaps with #496, which asks for the same thing on the recreate path.
Whichever is chosen, the mixed sequence needs a test: an exception on one attempt followed by merged: false on the next, asserting the outcome is attributed to the refusal.
Acceptance
- A stored exception cannot outlive the attempt that raised it when a later attempt gets an answer
-
_get_failure_summaryreports the refusal, not the superseded transport error, for the mixed sequence - The four existing consumers keep their current behaviour for the unmixed cases, with tests showing so
-
uv run pytestgreen; quality gate unchanged
Related
- Fix(merge): Let a cleared conflict be withdrawn #503 — where this was found; classifies a post-rebase failure from this field
- Feat: Recreate dispatch cannot tell a refusal from a failed attempt #496 — wants the same terminal-outcome plumbing on the recreate path
- Fix: Diagnose merge failures from live state, not the rejection #485 — introduced
merge_refused, the flag the misattribution affects
Activity
Fix(merge): Read the attempt that ended the merge
Fix(merge): Read the attempt that ended the merge