Skip to content

Commit ac5ffbd

Browse files
fix(review): skip unlinked-issue verify rate ceiling without AI binding (#8446)
Closes #8356 Co-authored-by: loopover-orb[bot] <296761690+loopover-orb[bot]@users.noreply.github.com>
1 parent 39ee47a commit ac5ffbd

2 files changed

Lines changed: 57 additions & 1 deletion

File tree

src/review/unlinked-issue-guardrail.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,10 @@ export async function resolveUnlinkedIssueMatchDisposition(env: Env, input: Reso
242242
if (authorLogin && (await isOverUnlinkedIssueVerifyRateCeiling(env, authorLogin))) return unlinkedIssueVerifyCapacityHold("rate");
243243
if (await isUnlinkedIssueVerifyBudgetExceeded(env, candidates.length)) return unlinkedIssueVerifyCapacityHold("budget");
244244
for (const candidate of candidates) {
245-
if (authorLogin) await recordUnlinkedIssueVerifyAttempt(env, input.repoFullName, input.pullNumber, authorLogin);
245+
// #8356: mirror the spend-budget gate below — missing/no-op AI bindings fail closed to NO_MATCH
246+
// without burning the per-actor rate-ceiling counter as if a real verifier call were possible.
246247
const hadAiBinding = hasUnlinkedIssueVerifyAiBinding(env);
248+
if (hadAiBinding && authorLogin) await recordUnlinkedIssueVerifyAttempt(env, input.repoFullName, input.pullNumber, authorLogin);
247249
const verdict = await verifyUnlinkedIssueMatch(env, {
248250
prTitle: input.prTitle,
249251
prBody: input.prBody,

test/unit/unlinked-issue-guardrail.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,60 @@ describe("resolveUnlinkedIssueMatchDisposition", () => {
463463
expect(usedAfter).toBe(usedBefore);
464464
});
465465

466+
// #8356: rate-ceiling attempts must not accumulate on no-op NO_MATCH paths when AI.run is absent —
467+
// same gate already applied to recordUnlinkedIssueVerifyUsage above.
468+
it("does not burn the per-actor rate ceiling when no AI binding is available, even across many candidate checks", async () => {
469+
const env = createTestEnv({});
470+
// Seed more candidate-quality issues than the rate ceiling (15) so a pre-fix path would exhaust it.
471+
for (let i = 1; i <= 20; i++) {
472+
await seedIssue(
473+
env,
474+
i,
475+
`webhook retry duplicate bug variant ${i}`,
476+
"retries duplicate events under load, needs a dedup key for webhook retry duplicate bug",
477+
);
478+
}
479+
480+
for (let pull = 101; pull <= 120; pull++) {
481+
const result = await resolveUnlinkedIssueMatchDisposition(env, {
482+
...BASE_INPUT,
483+
pullNumber: pull,
484+
config: config(),
485+
});
486+
expect(result).toBeUndefined();
487+
}
488+
489+
expect(
490+
await countRecentAuditEventsForActor(env, "contributor-a", UNLINKED_ISSUE_VERIFY_ATTEMPT_AUDIT_EVENT_TYPE, "2000-01-01T00:00:00.000Z"),
491+
).toBe(0);
492+
493+
// Once a binding is present, a genuine verification attempt is recorded normally.
494+
const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict({ matched: false, confidence: 0 })) }));
495+
const envWithAi = createTestEnv({ AI: { run } as unknown as Ai });
496+
await seedIssue(envWithAi, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key");
497+
await resolveUnlinkedIssueMatchDisposition(envWithAi, { ...BASE_INPUT, pullNumber: 201, config: config() });
498+
expect(run).toHaveBeenCalled();
499+
expect(
500+
await countRecentAuditEventsForActor(
501+
envWithAi,
502+
"contributor-a",
503+
UNLINKED_ISSUE_VERIFY_ATTEMPT_AUDIT_EVENT_TYPE,
504+
"2000-01-01T00:00:00.000Z",
505+
),
506+
).toBeGreaterThan(0);
507+
});
508+
509+
it("does not record a rate-ceiling attempt when AI is present but not callable (non-function run)", async () => {
510+
const env = createTestEnv({ AI: { run: "not-a-function" } as unknown as Ai });
511+
await seedIssue(env, 7, "webhook retry duplicate bug", "retries duplicate events under load, needs a dedup key");
512+
513+
await resolveUnlinkedIssueMatchDisposition(env, { ...BASE_INPUT, config: config() });
514+
515+
expect(
516+
await countRecentAuditEventsForActor(env, "contributor-a", UNLINKED_ISSUE_VERIFY_ATTEMPT_AUDIT_EVENT_TYPE, "2000-01-01T00:00:00.000Z"),
517+
).toBe(0);
518+
});
519+
466520
it("swallows a usage-recording write failure without affecting the verification result", async () => {
467521
const run = vi.fn(async () => ({ response: JSON.stringify(aiVerdict()) }));
468522
const env = createTestEnv({ AI: { run } as unknown as Ai });

0 commit comments

Comments
 (0)