Skip to content

MergeGate Merge Outcome

Issue2104, plan slice C9a. This change is live for every repo that MergeGate merges. The release hold (C9b, live for dashecorp/dashe-website only) and the signed release route (C9c) build on it. For every other repo, when MergeGate decides to merge did not change in C9a. Since rc#2053, the guard also requires Review-E's approval at the head, in every repo, and pins the PUT to that head.

Merge() returns a MergeOutcome

MergeOutcome is a pure Core value. MergeGate.Merge(repo, pr) (source) is now internal and returns one:

Path in Merge() Outcome Reason HttpStatus
LOCATION is set and the multi-location fence refuses the repo's tenant (rig-conductor#2561). Checked first, before the guard: no GitHub call, nothing cleared Deferred location_fenced: <reason>, e.g. location_fenced: mapped_elsewhere –
No GITHUB_TOKEN Failed no_token –
The guard says not ready: a check has not passed, or review-e-bot's latest verdict does not approve the head (rc#2053) Deferred the guard's reason: pending: build, review_not_at_head, review_lookup_failed, or two of them joined by ; –
GitHub no longer reports the PR open (merged or closed since the trigger; rc#1976) Deferred pr_not_open (no MERGE_GATE_WAITING, nothing cleared) –
The head moved between the guard's read and the title fetch (rc#2053) Deferred head_moved_during_merge –
The GET /pulls/{pr} title fetch fails Failed pr_fetch_failed GitHub's status
The squash PUT /pulls/{pr}/merge fails (it sends sha = the verified head, so a head that moved is 409) Failed merge_put_failed GitHub's status, such as 405 or 409
The squash PUT throws (no answer) Failed merge_put_failed – (merged: and merging: are freed, as for a refused PUT)
GitHub merged Merged – – (Sha is the squash SHA, or null if GitHub omitted it)

Held and Deferred("merging") for an approved task PR come from the operator release hold (C9b, details). Since rc#2053, review_not_at_head and review_lookup_failed come from the guard for every PR, before the hold runs. Every outcome other than Merged carries a non-blank reason. A guard defer emits MERGE_GATE_WAITING with the missing conditions (CI, review). It clears only the fields GitHub showed to be stale: ci-passed when a check has not passed, and review-approved when Review-E's verdict does not approve the head. Unreadable reviews clear nothing (table). A failed PUT still deletes the pre-set merged: key.

MERGE_GATE_MERGED only after a real merge

The all-conditions path in SetConditionAndTryMerge used to emit MERGE_GATE_MERGED before it called Merge(), so a guard defer or a refused PUT still showed "merge gate — all conditions met". It now emits the event only when Merge() returns Merged, after MERGED and ISSUE_*. The other two callers (no required checks, and the 30 s CI timeout) never emitted it and still do not, nor does the rc#2425 recheck. The rc#2365 sweep (RetryAsync) runs the all-conditions path and emits it the same way, only after Merged. The event has no projection; it feeds the Discord line and re-render.

An unresolved dashecorp repo coalesces to tenant-0 (B1)

BindTenantFromRepoAsync now sets ctx.TenantId = TenantMatch.CoalesceInDefaultOrg(repo, resolved): the resolved tenant, else tenant-0 for a repo in the dashecorp org (TenantMatch.DefaultGithubOrg), the same coalesce as the GitHub webhook in Program.cs, whose repos are pre-filtered to that org. Before, a null resolution left the context blank. The MERGED write after a successful PUT then threw UnattributedTenantException (TenantContextGuard.RequireTenant), and the merge was never recorded. A resolved tenant binds exactly as before, and a blank answer still fails closed. An unresolved repo in any other org also stays blank and fails closed (rc#1691): POST /api/merge passes a caller-supplied, unfiltered repo into SetConditionAndTryMerge, and such a merge must never be recorded under invotek. All four MergeGate scopes use it (EmitEventAsync, NotifyPendingCiStarted, CompletePendingAfterCi, Merge). DuplicateCloseService has its own copy of the bind and is unchanged.

The coalesce matches the webhook's only while both resolve the same tenant. MergeGate resolves with the repo alone, while the webhook also passes the delivery's installation id and org, so an unpinned repo can land in two tenant databases once another active tenant carries that installation id or shares the org. For the task repo this splits HUMAN_GATE_TRIGGERED from REVIEW_PASSED. The fix is operational: pin the repo before such a tenant is activated (see the routes' precondition).

Restoring several conditions at once (rig-conductor#2562)

SetConditionsAndTryMerge(repo, pr, conditions) sets every condition, then evaluates once, as SetConditionAndTryMerge (which now calls it) does after one. The activation reconcile sweep uses it to restore ci-passed and review-approved from GitHub when a tenant becomes active at a new location (multi-location fencing). It is still only a trigger: Merge() checks the fence and the guard as for any other caller.

Post-merge bookkeeping is guarded

Once GitHub has merged, the MERGED write, the dup-close Task.Run, the main-CI lookup with its pending-done: key, and ISSUE_IN_DEPLOY or ISSUE_DONE run inside one try/catch. A failure is logged as merged, but post-merge bookkeeping failed, the outcome stays Merged, and merged: stays set. An unreadable merge response is Merged(null).

Tests

MergeOutcomeTests cover the value. MergeGateTests is the first MergeGate harness: an NSubstitute IConnectionMultiplexer, a stub HttpMessageHandler, an event store that binds the tenant like the Marten write session, and one ordered log of GitHub calls and appends. The token comes through an internal constructor, so the tests never set the process-global GITHUB_TOKEN. Cases: - merged, guard-deferred, PUT 405, PR fetch 502 and no token, each with its outcome and side effects; - MERGE_GATE_MERGED after the PUT and after MERGED, and never on a 405 or a defer; - an unresolved repo records MERGED and ISSUE_DONE under invotek, and a resolved repo keeps its tenant; - a throwing event store still returns Merged and keeps merged:; - (rc#2053) every non-approval review shape, the /api/merge path, unreadable and paginated reviews, a head that moved after the guard, and the pinned PUT.

Watch after deploy

Before C9b merges, watch one real MergeGate merge on any repo. Confirm in the Activity tab or /api/events/recent that it still records MERGED and that MERGE_GATE_MERGED now follows it.