Skip to content

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 sees checks pending: dotnet test and defers, clearing ci-passed. POST /api/merge then takes the 30 s path, and its guard defers too.
  • 10:22:54Z dotnet test completes with success. 10:23:05Z its check_run.completed is delivered: 200, and no CI passed, condition met or 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

  1. Look the PR up by head SHA (path 1). On a success with an empty pull_requests, the handler calls MergeGate.OpenPrsAtHeadAsync: GET /repos/{repo}/commits/{sha}/pulls on the gate's GITHUB_TOKEN client, keeping open PRs into this repo whose head is exactly the SHA. The first then takes the normal success path (CI_PASSED once per head, SetConditionAndTryMerge(ci-passed)), which is idempotent. A failure with no PR is still skipped: no new CI-fix dispatch.
  2. A failed PR read still re-runs the gate on a success (path 2). The gate needs nothing from it.
  3. Every early exit logs one line (paths 1-5): [Webhook] check_run.completed skipped (<reason>): <repo>@<sha8> check '<name>'.
  4. 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 min dotnet test timeout), 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.