From 4a3c89c9f713694aec7e88dafe7b814a7e8a0eda Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Fri, 24 Jul 2026 08:24:05 -0500 Subject: [PATCH] fix(review): skip unlinked-issue verify rate ceiling without AI binding Closes #8356 --- src/review/unlinked-issue-guardrail.ts | 4 +- test/unit/unlinked-issue-guardrail.test.ts | 54 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/review/unlinked-issue-guardrail.ts b/src/review/unlinked-issue-guardrail.ts index 005da896a9..902aadfac2 100644 --- a/src/review/unlinked-issue-guardrail.ts +++ b/src/review/unlinked-issue-guardrail.ts @@ -242,8 +242,10 @@ export async function resolveUnlinkedIssueMatchDisposition(env: Env, input: Reso if (authorLogin && (await isOverUnlinkedIssueVerifyRateCeiling(env, authorLogin))) return unlinkedIssueVerifyCapacityHold("rate"); if (await isUnlinkedIssueVerifyBudgetExceeded(env, candidates.length)) return unlinkedIssueVerifyCapacityHold("budget"); for (const candidate of candidates) { - if (authorLogin) await recordUnlinkedIssueVerifyAttempt(env, input.repoFullName, input.pullNumber, authorLogin); + // #8356: mirror the spend-budget gate below — missing/no-op AI bindings fail closed to NO_MATCH + // without burning the per-actor rate-ceiling counter as if a real verifier call were possible. const hadAiBinding = hasUnlinkedIssueVerifyAiBinding(env); + if (hadAiBinding && authorLogin) await recordUnlinkedIssueVerifyAttempt(env, input.repoFullName, input.pullNumber, authorLogin); const verdict = await verifyUnlinkedIssueMatch(env, { prTitle: input.prTitle, prBody: input.prBody, diff --git a/test/unit/unlinked-issue-guardrail.test.ts b/test/unit/unlinked-issue-guardrail.test.ts index 6fec4f95b6..90347ca6dc 100644 --- a/test/unit/unlinked-issue-guardrail.test.ts +++ b/test/unit/unlinked-issue-guardrail.test.ts @@ -463,6 +463,60 @@ describe("resolveUnlinkedIssueMatchDisposition", () => { expect(usedAfter).toBe(usedBefore); }); + // #8356: rate-ceiling attempts must not accumulate on no-op NO_MATCH paths when AI.run is absent — + // same gate already applied to recordUnlinkedIssueVerifyUsage above. + it("does not burn the per-actor rate ceiling when no AI binding is available, even across many candidate checks", async () => { + const env = createTestEnv({}); + // Seed more candidate-quality issues than the rate ceiling (15) so a pre-fix path would exhaust it. + for (let i = 1; i <= 20; i++) { + await seedIssue( + env, + i, + `webhook retry duplicate bug variant ${i}`, + "retries duplicate events under load, needs a dedup key for webhook retry duplicate bug", + ); + } + + for (let pull = 101; pull <= 120; pull++) { + const result = await resolveUnlinkedIssueMatchDisposition(env, { + ...BASE_INPUT, + pullNumber: pull, + config: config(), + }); + expect(result).toBeUndefined(); + } + + expect( + await countRecentAuditEventsForActor(env, "contributor-a", UNLINKED_ISSUE_VERIFY_ATTEMPT_AUDIT_EVENT_TYPE, "2000-01-01T00:00:00.000Z"), + ).toBe(0); + + // Once a binding is present, a genuine verification attempt is recorded normally. + const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict({ matched: false, confidence: 0 })) })); + const envWithAi = createTestEnv({ AI: { run } as unknown as Ai }); + await seedIssue(envWithAi, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + await resolveUnlinkedIssueMatchDisposition(envWithAi, { ...BASE_INPUT, pullNumber: 201, config: config() }); + expect(run).toHaveBeenCalled(); + expect( + await countRecentAuditEventsForActor( + envWithAi, + "contributor-a", + UNLINKED_ISSUE_VERIFY_ATTEMPT_AUDIT_EVENT_TYPE, + "2000-01-01T00:00:00.000Z", + ), + ).toBeGreaterThan(0); + }); + + it("does not record a rate-ceiling attempt when AI is present but not callable (non-function run)", async () => { + const env = createTestEnv({ AI: { run: "not-a-function" } as unknown as Ai }); + await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key"); + + await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() }); + + expect( + await countRecentAuditEventsForActor(env, "contributor-a", UNLINKED_ISSUE_VERIFY_ATTEMPT_AUDIT_EVENT_TYPE, "2000-01-01T00:00:00.000Z"), + ).toBe(0); + }); + it("swallows a usage-recording write failure without affecting the verification result", async () => { const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) })); const env = createTestEnv({ AI: { run } as unknown as Ai });