diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 385fd80027..1b05ee6bda 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -294,6 +294,7 @@ import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { resolveRepositorySettings } from "../settings/repository-settings"; import type { LocalBranchAnalysisInput } from "../signals/local-branch"; import { + isEnabled, runGittensoryAiReview, type InlineFinding, } from "../services/ai-review"; @@ -3385,6 +3386,23 @@ export function buildSecretScanDiff( * Fully fail-safe: disabled / ineligible author / no head SHA / non-ok AI / any thrown error → no finding * and no notes. */ + +export async function shouldStartAiReviewForAdvisory( + env: Env, + args: { + settings: RepositorySettings; + advisory: Pick>, "headSha">; + repoFullName: string; + author: string | null; + confirmedContributor: boolean; + skipAiReview?: boolean | undefined; + }, +): Promise { + const packAllowsAnyAuthorBlockingReview = args.settings.gatePack === "oss-anti-slop" && args.settings.aiReviewMode === "block"; + if (args.skipAiReview || args.settings.aiReviewMode === "off" || (!args.confirmedContributor && !packAllowsAnyAuthorBlockingReview) || !args.advisory.headSha || !isEnabled(env.AI_SUMMARIES_ENABLED) || !isEnabled(env.AI_PUBLIC_COMMENTS_ENABLED) || !env.AI) return false; + return !(isReputationEnabled(env) && isConvergenceRepoAllowed(env, args.repoFullName) && (await shouldSkipAiForReputation(env, { project: args.repoFullName, submitter: args.author }))); +} + export async function runAiReviewForAdvisory( env: Env, args: { @@ -4281,10 +4299,15 @@ async function maybePublishPrPublicSurface( settings.contributorBlacklist, ); const aiReviewWillRun = - !webhook.skipAiReview && - settings.aiReviewMode !== "off" && - Boolean(advisory.headSha) && - !authorBlacklisted; + !authorBlacklisted && + (await shouldStartAiReviewForAdvisory(env, { + settings, + advisory, + repoFullName, + author, + confirmedContributor, + skipAiReview: webhook.skipAiReview, + })); // Post a transient "🟪 reviewing…" placeholder BEFORE the AI runs so contributors see the bot // is actively working rather than silent. In-place upsert: once the final verdict is ready it // overwrites this comment. Best-effort — a failed post never aborts the review. (#reviewing-placeholder) diff --git a/test/unit/ai-review-advisory.test.ts b/test/unit/ai-review-advisory.test.ts index c548105569..417b3d3ae4 100644 --- a/test/unit/ai-review-advisory.test.ts +++ b/test/unit/ai-review-advisory.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildAiReviewDiff, runAiReviewForAdvisory } from "../../src/queue/processors"; +import { buildAiReviewDiff, runAiReviewForAdvisory, shouldStartAiReviewForAdvisory } from "../../src/queue/processors"; import { BEST_REVIEW_MODELS } from "../../src/services/ai-review"; import { upsertRepositoryAiKey } from "../../src/db/repositories"; import type { Advisory, PullRequestFileRecord, RepositorySettings } from "../../src/types"; @@ -66,6 +66,35 @@ function aiEnv(run: () => Promise, flags = true) { }); } +describe("shouldStartAiReviewForAdvisory", () => { + const enabledEnv = () => aiEnv(async () => ({ response: notesOnlyJson() })); + const base = { settings: { aiReviewMode: "advisory", gatePack: "gittensor" } as RepositorySettings, advisory: advisory(), repoFullName: "acme/widgets", author: "alice", confirmedContributor: true }; + + it("matches the AI review entry gates before the reviewing placeholder is posted", async () => { + await expect(shouldStartAiReviewForAdvisory(enabledEnv(), base)).resolves.toBe(true); + await expect(shouldStartAiReviewForAdvisory(enabledEnv(), { ...base, skipAiReview: true })).resolves.toBe(false); + await expect(shouldStartAiReviewForAdvisory(enabledEnv(), { ...base, settings: { aiReviewMode: "off" } as RepositorySettings })).resolves.toBe(false); + await expect(shouldStartAiReviewForAdvisory(enabledEnv(), { ...base, confirmedContributor: false })).resolves.toBe(false); + await expect(shouldStartAiReviewForAdvisory(enabledEnv(), { ...base, settings: { aiReviewMode: "block", gatePack: "oss-anti-slop" } as RepositorySettings, confirmedContributor: false })).resolves.toBe(true); + const noSha = advisory(); + delete (noSha as Partial).headSha; + await expect(shouldStartAiReviewForAdvisory(enabledEnv(), { ...base, advisory: noSha })).resolves.toBe(false); + }); + + it("does not start when AI comments are disabled or the Workers AI binding is unavailable", async () => { + const commentsDisabled = createTestEnv({ AI: { run: vi.fn() } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "false" }); + await expect(shouldStartAiReviewForAdvisory(commentsDisabled, base)).resolves.toBe(false); + const missingBinding = createTestEnv({ AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true" }); + await expect(shouldStartAiReviewForAdvisory(missingBinding, base)).resolves.toBe(false); + }); + + it("does not start when the reputation gate downgrades the PR to deterministic-only", async () => { + const env = createTestEnv({ AI: { run: vi.fn() } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", GITTENSORY_REVIEW_REPUTATION: "true", GITTENSORY_REVIEW_REPOS: "acme/widgets" }); + await env.DB.prepare("INSERT INTO submitter_stats (project, submitter, submissions, merged, closed, manual, last_seen) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)").bind("acme/widgets", "alice", 8, 0, 8, 0).run(); + await expect(shouldStartAiReviewForAdvisory(env, base)).resolves.toBe(false); + }); +}); + describe("runAiReviewForAdvisory", () => { it("no-ops when aiReviewMode is off", async () => { const adv = advisory(); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 0447fea8e3..6dca225356 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -778,7 +778,8 @@ describe("queue processors", () => { autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", - aiReviewMode: "advisory", + aiReviewMode: "block", + gatePack: "oss-anti-slop", }); const commentBodies: string[] = []; let firstCommentWasPlaceholder = false; @@ -823,6 +824,55 @@ describe("queue processors", () => { expect(commentBodies.some((body) => !body.includes("is reviewing"))).toBe(true); }); + it("does not post the 🟪 reviewing placeholder when public AI comments are disabled (regression)", async () => { + let aiCalls = 0; + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + AI: { run: async () => { aiCalls += 1; return { response: JSON.stringify({ assessment: "Looks fine.", blockers: [], nits: [], suggestions: [] }) }; } } as unknown as Ai, + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "false", + AI_DAILY_NEURON_BUDGET: "100000", + }); + await persistRegistrySnapshot(env, normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z")); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", commentMode: "all_prs", publicSurface: "comment_only", autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", aiReviewMode: "advisory" }); + const commentBodies: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/8/files")) return Response.json([{ filename: "src/a.ts", status: "modified", additions: 1, deletions: 0, changes: 1, patch: "@@\n+export const ok = true;" }]); + if (url.endsWith("/pulls/8")) return Response.json({ number: 8, title: "Clean PR", state: "open", user: { login: "contributor" }, head: { sha: "a8" }, labels: [], body: "Closes #1" }); + if (url.includes("/commits/a8/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/commits/a8/status")) return Response.json({ state: "success", statuses: [] }); + if (url.includes("/issues/1")) return Response.json({ number: 1, title: "Issue", state: "open", labels: [], user: { login: "reporter" } }); + if (url.includes("/issues/8/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/8/comments") && method === "POST") { + commentBodies.push(String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? "")); + return Response.json({ id: 1 }, { status: 201 }); + } + if (url.includes("/branches/")) return Response.json({ protected: false, protection: { required_status_checks: { contexts: [] } } }); + return Response.json({}); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "reviewing-placeholder-disabled-ai", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 8, title: "Clean PR", state: "open", user: { login: "contributor" }, head: { sha: "a8" }, labels: [], body: "Closes #1" }, + }, + }); + + expect(aiCalls).toBe(0); + expect(commentBodies.length).toBe(1); + expect(commentBodies[0]).not.toContain("is reviewing"); + expect(commentBodies[0]).not.toContain("🟪"); + }); + it("agent re-gate sweep re-reviews each stale open PR (installation id) and swallows a failing re-review", async () => { const env = createTestEnv({}); await upsertInstallation(env, { action: "created", installation: { id: 9001, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] } });