MergeGate re-runs the gate when the last check completes after the approval (rc#2425)¶
Pure decisions in MergeGateRetriggerPolicy, tests first.
Evidence (PR #2421, head cb06d83c, 2026-09-25)¶
- 10:21:33Z Review-E approves:
ALL conditions met — merging, the guard seeschecks pending: dotnet testand defers, clearingci-passed.POST /api/mergethen takes the 30 s path, and its guard defers too. - 10:22:54Z
dotnet testcompletes withsuccess. 10:23:05Z itscheck_run.completedis delivered: 200, and noCI passed,condition metor guard line, although both earlier checks on the head logged them. Nothing retried: the PR stayed approved, green and CLEAN for 14 minutes, until merged by hand.
Root cause¶
A guard defer ends only with a later trigger, here the last check's own delivery. Ways it can fail (main f380fbf):
| # | Path | Where | Logged before |
|---|---|---|---|
| 1 | pull_requests empty: 200 {skipped, "no PR associated"} |
Program.cs:2641 |
no |
| 2 | GET /pulls/{n} not 2xx: falls through to the generic 200 {received} |
Program.cs:2656 |
no |
| 3 | a conclusion other than success/failure |
Program.cs:2623 |
no |
| 4 | a check name in the skip list | Program.cs:2636 |
no |
| 5 | an exception in the handler | Program.cs:2740 |
yes, without repo, SHA or check |
| 6 | the gate re-ran, but its guard read still saw the check running (or failed), so it deferred again | MergeGate.cs:584 |
yes |
| 7 | the delivery never reached the conductor | — | — |
3 and 4 do not fit (dotnet test, success); 5 and 6 would have logged. 1 fits best: a 200 with no log line and no
GitHub call, exactly what was seen. 2 fits too, but needs a GitHub error on a read that worked for both earlier checks on
the same head. The payload is not visible, so all are fixed.
The fix¶
- Look the PR up by head SHA (path 1). On a success with an empty
pull_requests, the handler callsMergeGate.OpenPrsAtHeadAsync:GET /repos/{repo}/commits/{sha}/pullson the gate'sGITHUB_TOKENclient, keeping open PRs into this repo whose head is exactly the SHA. The first then takes the normal success path (CI_PASSEDonce per head,SetConditionAndTryMerge(ci-passed)), which is idempotent. A failure with no PR is still skipped: no new CI-fix dispatch. - A failed PR read still re-runs the gate on a success (path 2). The gate needs nothing from it.
- Every early exit logs one line (paths 1-5):
[Webhook] check_run.completed skipped (<reason>): <repo>@<sha8> check '<name>'. - A bounded recheck (paths 2, 6, 7). A defer whose one reason is checks still running (so Review-E approves the head)
starts one recheck per PR:
Merge()every 2 min, 15 passes at most (30 min, over the 20 mindotnet testtimeout), stopping at the first pass that ends any other way.SET NX merge-gate-recheck:{repo}#{pr}(TTL two intervals, renewed each pass) keeps it to one across pods. It needs no new service or DI change; the reconciliation tick works from the issue projection and would need a new PR scan.
The guard is unchanged (rc#2350): it merges only an open PR whose current head review-e-bot's latest verdict approves
with every non-skip check passed; every recheck pass is a full Merge(). The 30 s path is unchanged. Its "merging
anyway" has only re-run the guard since rc#1028, which reads like a bypass; the recheck makes it redundant. Left as is.
Not covered: a restart, or a recheck pass whose guard read fails, ends the recheck (the next defer on running checks
starts a new one). Of several open PRs with one head, the first is re-run, as the handler did with pull_requests[0].
Tests¶
MergeGateRetriggerPolicyTests; MergeGateLateCheckE2eTests (real webhook, Postgres, serial: it reads the Console): merged after an
empty or populated pull_requests, guard unchanged, a line per exit; MergeGateTests: the recheck merges, is one per PR and bounded.