You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(queue): add a per-PR actuation mutex for the draft-dodge and reopen-reclose paths
Two different webhook deliveries for the same PR (e.g. a reopened
event and a concurrent check_suite completed event) could be dequeued
by separate workers at nearly the same time. Both would read the same
stale-but-still-"current" state, both pass their own freshness checks,
and both independently fire a mutating call — a TOCTOU window with no
per-PR mutex anywhere in the actuation path.
Add a lightweight interim mutex (short-TTL transient-cache claim,
best-effort release) and wrap the draft-dodge close and reopen-reclose
handlers with it — the two mutating webhook-triggered paths that
weren't already covered by an existing per-PR lock. A lock-contended
caller fails open (skips this pass); the delivery holding the lock is
evaluating the same PR, and the periodic sweep is the backstop if this
specific trigger is dropped.
Deliberately NOT the queue-level "widen the coalesce lookup to match
status='processing'" interim step the issue also floats: that would
have enqueue() silently UPDATE a claimed row's payload, which never
gets re-read before the claiming worker deletes the row on completion
— a coalesce that reports success while permanently discarding the
new event's trigger. The per-PR mutex avoids that failure mode
entirely. A full per-PR Durable Object (SubmissionLock) remains a
separate, larger follow-up per the existing TODO in env.d.ts.
detail: `${pullRequestFreshnessDetail(freshness)} — draft-dodge close not executed`,
3802
-
metadata: {
3803
-
deliveryId,
3804
-
repoFullName,
3805
-
headSha: pr.headSha,
3806
-
blockerCodes: block.blockerCodes,
3807
-
},
3808
-
}).catch(()=>undefined);
3809
-
}else{
3810
-
constcodes=block.blockerCodes.join(", ");
3811
-
awaitcreateIssueComment(
3812
-
env,
3813
-
installationId,
3814
-
repoFullName,
3815
-
pr.number,
3816
-
`Gate verdict stands for this commit — converting to draft does not reset the review. Re-submit a new PR with the issues addressed${codes ? ` (${codes})` : ""}.`,
3817
-
).catch(()=>undefined);
3818
-
awaitclosePullRequest(
3819
-
env,
3820
-
installationId,
3821
-
repoFullName,
3822
-
pr.number,
3823
-
).catch(()=>undefined);
3824
-
awaitrecordAuditEvent(env,{
3825
-
eventType: "github_app.draft_dodge_closed",
3826
-
actor: "gittensory",
3827
-
targetKey: `${repoFullName}#${pr.number}`,
3828
-
outcome: "completed",
3829
-
detail: `closed draft-dodge attempt by ${pr.authorLogin??"unknown"} — prior gate failure on headSha ${pr.headSha} stands`,
3830
-
metadata: {
3831
-
deliveryId,
3832
-
repoFullName,
3833
-
headSha: pr.headSha,
3834
-
blockerCodes: block.blockerCodes,
3835
-
},
3836
-
}).catch(()=>undefined);
3837
-
}
3838
-
}elseif(draftMode==="dry_run"){
3839
-
/* v8 ignore next -- a deleted-account PR yields a null author login; the fallback is defensive */
3840
-
constdraftAuthor=pr.authorLogin??"unknown";
3841
-
awaitrecordAuditEvent(env,{
3842
-
eventType: "github_app.draft_dodge_closed",
3843
-
actor: "gittensory",
3844
-
targetKey: `${repoFullName}#${pr.number}`,
3845
-
outcome: "completed",
3846
-
detail: `dry-run: would close draft-dodge attempt by ${draftAuthor} — prior gate failure on headSha ${pr.headSha} stands`,
3847
-
metadata: {
3848
-
deliveryId,
3849
-
repoFullName,
3850
-
headSha: pr.headSha,
3851
-
blockerCodes: block.blockerCodes,
3852
-
mode: "dry_run",
3853
-
},
3854
-
}).catch(
3855
-
/* v8 ignore next -- fail-safe: an audit write failure never blocks the handler */
3856
-
()=>undefined,
3857
-
);
3858
-
}
3859
-
}
3860
3788
}
3861
3789
if(
3862
3790
installationId&&
@@ -7715,16 +7643,195 @@ async function recordPrPanelRetriggerSkip(
7715
7643
});
7716
7644
}
7717
7645
7646
+
/** Draft-dodge guard (#converted-to-draft): a contributor converting an OPEN PR to draft cannot use draft state
7647
+
* to keep a gate-rejected PR alive. When a prior gate failure exists for the PR's current headSha (and the
7648
+
* block has not been maintainer-overridden), close the PR immediately — the gate verdict stands and does not
7649
+
* reset on draft conversion. Per-PR actuation-locked (#2135): a concurrent delivery for the same PR must not
7650
+
* evaluate + potentially mutate it at the same time. Lock-contended is a silent no-op for this pass — the
7651
+
* delivery holding the lock is handling this PR. */
detail: `${pullRequestFreshnessDetail(freshness)} — draft-dodge close not executed`,
7739
+
metadata: {
7740
+
deliveryId,
7741
+
repoFullName,
7742
+
headSha: pr.headSha,
7743
+
blockerCodes: block.blockerCodes,
7744
+
},
7745
+
}).catch(()=>undefined);
7746
+
}else{
7747
+
constcodes=block.blockerCodes.join(", ");
7748
+
awaitcreateIssueComment(
7749
+
env,
7750
+
installationId,
7751
+
repoFullName,
7752
+
pr.number,
7753
+
`Gate verdict stands for this commit — converting to draft does not reset the review. Re-submit a new PR with the issues addressed${codes ? ` (${codes})` : ""}.`,
7754
+
).catch(()=>undefined);
7755
+
awaitclosePullRequest(
7756
+
env,
7757
+
installationId,
7758
+
repoFullName,
7759
+
pr.number,
7760
+
).catch(()=>undefined);
7761
+
awaitrecordAuditEvent(env,{
7762
+
eventType: "github_app.draft_dodge_closed",
7763
+
actor: "gittensory",
7764
+
targetKey: `${repoFullName}#${pr.number}`,
7765
+
outcome: "completed",
7766
+
detail: `closed draft-dodge attempt by ${pr.authorLogin??"unknown"} — prior gate failure on headSha ${pr.headSha} stands`,
7767
+
metadata: {
7768
+
deliveryId,
7769
+
repoFullName,
7770
+
headSha: pr.headSha,
7771
+
blockerCodes: block.blockerCodes,
7772
+
},
7773
+
}).catch(()=>undefined);
7774
+
}
7775
+
}elseif(draftMode==="dry_run"){
7776
+
/* v8 ignore next -- a deleted-account PR yields a null author login; the fallback is defensive */
7777
+
constdraftAuthor=pr.authorLogin??"unknown";
7778
+
awaitrecordAuditEvent(env,{
7779
+
eventType: "github_app.draft_dodge_closed",
7780
+
actor: "gittensory",
7781
+
targetKey: `${repoFullName}#${pr.number}`,
7782
+
outcome: "completed",
7783
+
detail: `dry-run: would close draft-dodge attempt by ${draftAuthor} — prior gate failure on headSha ${pr.headSha} stands`,
7784
+
metadata: {
7785
+
deliveryId,
7786
+
repoFullName,
7787
+
headSha: pr.headSha,
7788
+
blockerCodes: block.blockerCodes,
7789
+
mode: "dry_run",
7790
+
},
7791
+
}).catch(
7792
+
/* v8 ignore next -- fail-safe: an audit write failure never blocks the handler */
7793
+
()=>undefined,
7794
+
);
7795
+
}
7796
+
}
7797
+
}
7798
+
7718
7799
/** Reopen-prevention (#one-shot-reopen): re-close a contributor's reopen of a PR that gittensory / a maintainer
7719
7800
* closed (closes are one-shot). Returns true when it re-closed (caller skips the re-review). Exempt: the bot's
7720
-
* own re-review reopens, owner/admin reopens, and a contributor reopening a PR they CLOSED THEMSELVES. */
7801
+
* own re-review reopens, owner/admin reopens, and a contributor reopening a PR they CLOSED THEMSELVES.
7802
+
* Per-PR actuation-locked (#2135): a concurrent delivery for the same PR (e.g. a check_suite completion racing
7803
+
* this reopen) must not evaluate + potentially mutate this PR at the same time. Lock-contended fails open
7804
+
* (returns false, falls through to normal re-review) — the delivery holding the lock is handling this PR. */
0 commit comments