From 509d6a5e35de4cd883dc4201dc333e4e1fbe88c3 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:59:38 -0700 Subject: [PATCH] fix(queue): re-gate every PR linked to changed issue --- src/queue/processors.ts | 11 +++++------ test/unit/queue.test.ts | 10 +++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 3f0902ec11..5c44d3dd4d 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -4102,8 +4102,8 @@ async function maybeInvalidateCiCacheOnLegacyCiEvent( * Wake linked PRs on an issue-side signal (#2259). Labeling/unlabeling (e.g. maintainer-only) or * assigning/unassigning on a linked ISSUE can flip a linked-issue hard-rule verdict, but that only gets * re-evaluated when the PR ITSELF receives a webhook or the staleness-ordered sweep eventually reaches it — - * which can lag for many cycles on a repo with more than a few open PRs. Enqueue a bounded, staggered batch of - * per-PR re-gate jobs for OPEN PRs that link this issue instead of doing the expensive live re-review inline. + * which can lag for many cycles on a repo with more than a few open PRs. Enqueue staggered per-PR re-gate jobs + * for EVERY open PR that links this issue instead of doing the expensive live re-review inline. * Uses its OWN coalesce window (issueLinkedPrReReviewCoalesced, * DISTINCT from CI-completion's — #2371): the two triggers are not interchangeable, so a shared window let an * unrelated CI re-review silently suppress a genuinely different issue-side signal. Within the issue-side @@ -4132,12 +4132,11 @@ async function maybeReReviewOnLinkedIssueChange( if (isConvergenceRepoAllowed(env, repoFullName)) { const openPullRequests = await listOpenPullRequests(env, repoFullName); // Issue-side label/assignment changes can flip linked-issue hard-rule verdicts from mergeable to close. - // Wake a rate-aware leading batch promptly; the staleness-ordered sweep still converges any linked tail - // without letting one issue webhook fan out unbounded foreground re-gates. Stagger the jobs so the expensive - // re-gates do not run inline or stampede the queue consumer. + // Wake every linked PR promptly: a silent tail can keep stale passing gate/check state after the issue flips + // to an ineligible hard-rule state. Stagger the jobs so the expensive re-gates do not run inline or stampede + // the queue consumer. const linkingPrs = openPullRequests .filter((pr) => pr.linkedIssues.includes(issueNumber)) - .slice(0, SWEEP_MAX_PRS) .map((pr) => ({ number: pr.number, createdAt: pr.createdAt ?? null })); for (const [index, pr] of linkingPrs.entries()) { const prNumber = pr.number; diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index e727911d74..15cd1de43e 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -2661,7 +2661,7 @@ describe("queue processors", () => { ]); }); - it("REGRESSION: issue-side linked PR wake caps linked PR fanout to the sweep budget", async () => { + it("REGRESSION: issue-side linked PR wake re-gates every linked PR instead of leaving a stale tail", async () => { const sent: Array<{ message: import("../../src/types").JobMessage; options?: QueueSendOptions }> = []; const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), @@ -2686,7 +2686,7 @@ describe("queue processors", () => { await processJob(env, { type: "github-webhook", - deliveryId: "issue-label-capped-fanout", + deliveryId: "issue-label-all-linked-fanout", eventName: "issues", payload: { action: "labeled", @@ -2698,13 +2698,13 @@ describe("queue processors", () => { }); expect(fetchCount).toBe(0); - expect(sent).toHaveLength(SWEEP_MAX_PRS); + expect(sent).toHaveLength(SWEEP_MAX_PRS + 2); expect(sent.map(({ message }) => message)).toEqual( - Array.from({ length: SWEEP_MAX_PRS }, (_, index) => + Array.from({ length: SWEEP_MAX_PRS + 2 }, (_, index) => expect.objectContaining({ type: "agent-regate-pr", repoFullName: "owner/agent-repo", prNumber: index + 1, installationId: 9001 }), ), ); - expect(sent.map(({ options }) => options)).toEqual([undefined, { delaySeconds: 10 }, { delaySeconds: 20 }]); + expect(sent.map(({ options }) => options)).toEqual([undefined, { delaySeconds: 10 }, { delaySeconds: 20 }, { delaySeconds: 30 }, { delaySeconds: 40 }]); }); it("REGRESSION (#2371): a coalesced issue-side signal schedules a trailing re-review so an add-then-remove sequence is never lost", async () => {