Skip to content

fix(review): guard recordPrOutcome's webhook path against double-counting - #10346

Merged
JSONbored merged 1 commit into
JSONbored:mainfrom
shin-core:fix/outcomes-wire-webhook-dedup-10332
Aug 4, 2026
Merged

fix(review): guard recordPrOutcome's webhook path against double-counting#10346
JSONbored merged 1 commit into
JSONbored:mainfrom
shin-core:fix/outcomes-wire-webhook-dedup-10332

Conversation

@shin-core

Copy link
Copy Markdown
Contributor

What & why

src/review/outcomes-wire.ts writes a pr_outcome row two independent, best-effort ways: directly after the bot's own merge/close mutation (recordTerminalActionOutcome), and from the inbound pull_request.closed webhook (recordPrOutcome).

recordTerminalActionOutcome already guards against double-work — it probes for an existing pr_outcome row for the target and returns early (skipping both the incr() and the write) when one exists:

const existing = await env.DB.prepare(
  "SELECT 1 AS x FROM review_audit WHERE target_id = ? AND event_type = 'pr_outcome' LIMIT 1",
).bind(targetId).first<{ x: number }>();
if (existing) return;

recordPrOutcome had no such probe — it always incremented loopover_pr_outcomes_total and always wrote. In the realistic production ordering the bot's own direct write lands first (right after the merge/close mutation), then GitHub delivers the closed webhook for the same action, landing in recordPrOutcome second. The duplicate row is harmless — every downstream reader (submitter-reputation.ts's LATEST_PR_OUTCOME_FILTER, the fleet export, computeGateEval) reads only the latest row per target — but the counter has no such defense, so one real PR outcome was counted twice.

The fix

Mirror recordTerminalActionOutcome's guard exactly in recordPrOutcome: compute targetId, probe for an existing pr_outcome row with the same query shape, and return early — skipping both the incr() and the appendReviewAudit/recordAuditEvent writes — when one exists. A probe-read failure logs (pr_outcome_webhook_probe_error) and proceeds with the write (fail-open), matching the direct path's catch-and-proceed, so a genuinely-new outcome is never silently dropped.

Unchanged: recordTerminalActionOutcome's own logic, the senderLogin === authorLogin self-close early return, and every other decision path in recordPrOutcome — this is scoped to the missing existence check + metric double-count only.

Tests (test/unit/outcomes-wire.test.ts)

  • The realistic ordering (the bug): recordTerminalActionOutcome writes first for PR feat(mcp): add local workspace intelligence v2 #42, then recordPrOutcome runs for the same PR via the webhook — asserts loopover_pr_outcomes_total{outcome="closed"} is 1, not 2 (read from renderMetrics()), and the row stays deduplicated. Fails on main (counter = 2).
  • Fail-open: when the probe SELECT throws, recordPrOutcome still writes the outcome and logs a warning — a lost outcome is worse than a duplicate.
  • The existing "whichever wins the race" test (webhook-first ordering) passes unmodified alongside these.

Validation

  • Diff line + branch coverage on src/review/outcomes-wire.ts is 100% — both arms of the new if (existing) return and the catch fail-open path are exercised (lcov BRDA/DA confirm taken=true and taken=false, and the catch line is hit).
  • npm run typecheck clean; npm run engine-parity:drift-check passes (not a twin — 7 pairs agree); npm run dead-exports:check clean.
  • git diff --check clean; no schema / migration / generated-artifact change.

Closes #10332

…ting

recordPrOutcome (the inbound pull_request.closed webhook path) always incremented
loopover_pr_outcomes_total and wrote a pr_outcome row, with no existence check.
recordTerminalActionOutcome (the bot's own direct-action path) already probes for
an existing pr_outcome row and skips both the metric increment and the write when
one exists.

In the realistic production ordering the direct write lands first (right after the
merge/close mutation), then GitHub delivers the closed webhook for the same action,
so the counter was incremented twice for one real PR outcome. The duplicate ROW is
harmless (every downstream reader takes the LATEST row per target), but the counter
had no such defense.

Mirror recordTerminalActionOutcome's guard exactly: probe for an existing pr_outcome
row before incrementing or writing, and return early when one exists. A probe-read
failure logs and proceeds (fail-open) so a genuinely-new outcome is never dropped.
No other decision logic in recordPrOutcome changes.

Closes JSONbored#10332
@shin-core
shin-core requested a review from JSONbored as a code owner August 4, 2026 06:05
@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@JSONbored
JSONbored merged commit 080065a into JSONbored:main Aug 4, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

review(outcomes-wire): guard recordPrOutcome's webhook path against double-counting loopover_pr_outcomes_total

2 participants