Skip to content

fix(queue): re-verify live PR state before draft-dodge and reopen-reclose closes - #2369

Merged
JSONbored merged 3 commits into
mainfrom
claude/draft-dodge-live-recheck
Jul 1, 2026
Merged

fix(queue): re-verify live PR state before draft-dodge and reopen-reclose closes#2369
JSONbored merged 3 commits into
mainfrom
claude/draft-dodge-live-recheck

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

What

Both the converted_to_draft draft-dodge handler and the reopen-reclose handler (maybeRecloseDisallowedReopen) go straight from their async DB reads (getGateBlockOutcome/isGlobalAgentFrozen, or the permission/closer-history lookups) to closePullRequest with no live GET first — unlike the main gate-close path, which routes every close through executeAgentMaintenanceActions's freshness guard immediately before the mutation.

In the window between webhook ingestion and the final PATCH, a maintainer could merge/close the PR themselves, or a fresh push could clear the underlying gate failure, and the close would still fire blind off the stale ingestion-time payload. A queued github-webhook job retried after a transient failure hits the same gap, since retries replay the originally captured payload rather than re-fetching (up to 3 retries, 30s delay per attempt — a real window).

Fix

Add a fetchPullRequestFreshness call immediately before each closePullRequest, mirroring executeAgentMaintenanceActions's existing freshness step, and deny the close with a denied audit outcome when the PR's live state or head SHA no longer matches what triggered the handler. Both call sites are structurally identical bypasses of the same pattern, so this applies the same fix to both.

Tests

  • Draft-dodge: denied (no comment, no close) when live state has moved; existing happy-path/freeze/dry-run/no-block/owner-exemption tests unaffected.
  • Reopen-reclose: denied (no comment, no close) when live state has moved; existing happy-path/autonomy-floor/freeze/dry-run tests unaffected.
  • Both: a recordAuditEvent failure on the new denial path is swallowed — the handler still completes without throwing (mirrors the existing fail-safe convention for this file's other .catch() bodies).

Full unsharded test:coverage green (5604 passed); typecheck green; npm audit clean.

Advances #1936. Closes #2130. Closes #2261.

@dosubot dosubot Bot added the size:M label Jul 1, 2026
@loopover-orb

loopover-orb Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Warning

🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨

⏸️ Gittensory review result - manual review recommended

Review updated: 2026-07-01 21:26:48 UTC

4 files · 1 AI reviewer · no blockers · readiness 75/100 · CI green · clean

⏸️ Suggested Action - Manual Review

  • Touches a guarded path — held for manual review

Review summary
The change correctly adds a live freshness check before the two close mutations that previously acted on webhook-era state, and the draft-dodge path now verifies the PR is still a draft rather than relying on head/state alone. The reopened-PR path also re-checks the reopener's permission immediately before closing, which covers the same-head authorization race described in the PR. The freshness utility keeps existing callers' behavior unchanged unless `requireDraft` is explicitly passed, and the added tests cover the new stale, no-longer-draft, promoted-reopener, and audit-failure paths.

Nits — 6 non-blocking
  • nit: src/queue/processors.ts:3505 and src/queue/processors.ts:7522 carry long incident-style comments in the hot path; trim them to the invariant being enforced and leave the detailed race narrative in the tests or issue.
  • nit: test/unit/queue.test.ts:10636 and test/unit/queue.test.ts:10909 mock `fetchPullRequestFreshness`, so the queue tests should assert the exact call arguments for `installationId`, `repoFullName`, `pullNumber`, and `expectedHeadSha` on both handlers, not only `requireDraft` on the draft-dodge case.
  • src/queue/processors.ts:3505: keep the comment focused on the rule, e.g. live state must still be open, same-head, and draft immediately before the close.
  • test/unit/queue.test.ts:10636: add `expect(fetchPullRequestFreshness).toHaveBeenCalledWith(env, expect.objectContaining({ installationId: 123, repoFullName: "JSONbored/gittensory", pullNumber: 42, expectedHeadSha: "abc123" }))` for the reopen stale case and the analogous assertion for draft-dodge.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.
  • Touches a guarded path — held for manual review — A maintainer must review and merge this change.
Signal Result Evidence
Code review ✅ No blockers 1 reviewer
Linked issue ✅ Linked #2130, #2261
Related work ⚠️ 3 scoped overlaps Top overlaps are listed below; lower-confidence bulk is hidden.
Change scope ❌ 8/20 High review scope from cached public metadata (size label size:L; 2 linked issues).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 67 registered-repo PR(s), 57 merged, 589 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 67 PR(s), 589 issue(s).
Gate result ⚠️ Not blocking Advisory; not blocking this PR.
Review context
Contributor next steps
  • Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Review top overlaps.
  • Add a concise scope and risk note.
  • Triage stale or unlinked PRs.
  • No action.
  • Check active issues and PRs before submitting.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by Gittensory, a quiet PR intelligence layer for OSS maintainers.

  • Re-run Gittensory review

@loopover-orb loopover-orb Bot added gittensor gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. labels Jul 1, 2026
@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.83%. Comparing base (28b345a) to head (6f94152).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2369   +/-   ##
=======================================
  Coverage   95.83%   95.83%           
=======================================
  Files         224      224           
  Lines       25006    25023   +17     
  Branches     9094     9100    +6     
=======================================
+ Hits        23964    23981   +17     
  Misses        428      428           
  Partials      614      614           
Files with missing lines Coverage Δ
src/github/pr-freshness.ts 100.00% <100.00%> (ø)
src/queue/processors.ts 91.18% <100.00%> (+0.06%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

JSONbored added 3 commits July 1, 2026 14:04
…lose closes

Both the converted_to_draft draft-dodge handler and the reopen-reclose
handler (maybeRecloseDisallowedReopen) go straight from their async DB
reads (getGateBlockOutcome/isGlobalAgentFrozen, or the
permission/closer-history lookups) to closePullRequest with no live
GET first — unlike the main gate-close path, which routes every close
through executeAgentMaintenanceActions's freshness guard immediately
before the mutation. In the window between webhook ingestion and the
final PATCH, a maintainer could merge/close the PR themselves, or a
fresh push could clear the underlying gate failure, and the close
would still fire blind off the stale ingestion-time payload. A queued
github-webhook job retried after a transient failure hits the same
gap, since retries replay the originally captured payload rather than
re-fetching.

Add a fetchPullRequestFreshness call immediately before each
closePullRequest, mirroring executeAgentMaintenanceActions's existing
freshness step, and deny the close with a "denied" audit outcome when
the PR's live state or head SHA no longer matches what triggered the
handler.
…nds on, not just head/state

Both live re-checks added for the draft-dodge and reopen-reclose close paths
only proved the PR was still open on the same head — but a same-head, still-
open PR can undergo a live transition that invalidates the SPECIFIC
justification each handler is acting on:

- draft-dodge: the author could convert the PR back to ready_for_review
  before the close fires, clearing the very draft state the close is
  supposed to be punishing.
- reopen-reclose: the reopener could be promoted to a write/maintain/admin
  collaborator in the window between the initial permission read and the
  close, retroactively authorizing the reopen this handler is about to undo.

fetchPullRequestFreshness gains an opt-in requireDraft check (used only by
the draft-dodge call site; every other caller's behavior is unchanged), and
the reopen-reclose handler re-runs its own hasMaintainerPermission check
immediately before the mutation instead of relying solely on the initial
read.
@JSONbored
JSONbored force-pushed the claude/draft-dodge-live-recheck branch from 01bb95d to 6f94152 Compare July 1, 2026 21:11
@dosubot dosubot Bot added size:L and removed size:M labels Jul 1, 2026
@JSONbored
JSONbored merged commit 6cef302 into main Jul 1, 2026
12 checks passed
@JSONbored
JSONbored deleted the claude/draft-dodge-live-recheck branch July 1, 2026 21:29
@github-project-automation github-project-automation Bot moved this from Todo to Done in gittensory - v1 roadmap Jul 1, 2026
JSONbored added a commit that referenced this pull request Jul 2, 2026
The new outcome:error regression test's /issues/42/events mock lacked a
"reopened" event for the payload's actual reopener, so the #2369 live
recheck #3 (reopenerSuperseded) denied the close before ever reaching the
PATCH-fails path this test targets. Add the missing "reopened" event,
matching the pattern already used by every other reopen-reclose test in
this file.
JSONbored added a commit that referenced this pull request Jul 2, 2026
* fix(queue): record the real outcome when a reopen-reclose fails

Both the warning comment and the actual close call in
maybeRecloseDisallowedReopen were wrapped in .catch(() => undefined),
but the function unconditionally wrote a github_app.reopen_reclosed
audit event with outcome:"completed" regardless of whether the close
API call actually succeeded. A 403 from reduced permissions, a 404,
or a transient 5xx was silently swallowed while the audit ledger kept
recording a successful re-close — an operator trusting the audit
trail would believe the one-shot close was enforced when the PR may
still be open. This mirrors the same audit-fidelity gap already fixed
on the draft-dodge path.

Capture the close call's settled result and branch the audit outcome
on it: "completed" only when closePullRequest actually resolves,
"error" otherwise, with the underlying error captured in metadata.
The courtesy comment's own failure still never affects this — it's
independent of whether the close succeeded.

* test(queue): fix reopen-reclose CI failure test's stale event timeline

The new outcome:error regression test's /issues/42/events mock lacked a
"reopened" event for the payload's actual reopener, so the #2369 live
recheck #3 (reopenerSuperseded) denied the close before ever reaching the
PATCH-fails path this test targets. Add the missing "reopened" event,
matching the pattern already used by every other reopen-reclose test in
this file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier.

Projects

No open projects
Status: Done

1 participant