diff --git a/.loopover.yml.example b/.loopover.yml.example index e7cf0d59f6..ac2597f896 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -336,15 +336,16 @@ gate: # off | advisory | block. Default: off. # advisory — posts AI review notes only. # block — a dual-model high-confidence consensus defect may become a - # blocker (confirmed-contributors only). + # blocker. mode: off # Use the maintainer's frontier model for the *advisory* write-up when a # provider key is configured. The consensus blocker always uses the free # Workers-AI pair, so BYOK never changes who can be blocked. # Bool. Default: false. byok: false - # Review every PR author when AI review is enabled. Keep false to spend - # model calls only on the engine's default eligible authors. + # Review every PR author when AI review is enabled. Only has an effect + # when `settings.aiReviewConfirmedContributorsOnly` (below) is also true -- + # otherwise every author is already reviewed by default. # Bool. Default: false. allAuthors: false # BYOK provider. anthropic | openai | null. Default: null (use the stored @@ -680,6 +681,13 @@ settings: # the safe-by-default add-only behavior. Bool. Default: false. hardGuardrailGlobsOverridesInvariants: false + # Restrict AI review (gate.aiReview.mode above) to confirmed Gittensor contributors only. Default: + # false -- AI review runs for EVERY PR author once aiReview.mode is not off, since security/quality + # review should not be weaker for a new or unconfirmed contributor's PR than for an established one's. + # Set true only if you deliberately want to bound AI spend to registered miners; gate.aiReview.allAuthors + # above can then still widen back out to every author within that narrowed mode. Bool. Default: false. + aiReviewConfirmedContributorsOnly: false + # Require a linked issue on every PR (dashboard equivalent of the toggle that # can auto-promote gate.linkedIssue to block). Bool. Default: false. requireLinkedIssue: false diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 2b6d0e2be0..7141c4a4cd 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -9639,6 +9639,10 @@ "hardGuardrailGlobsOverridesInvariants": { "type": "boolean", "nullable": true + }, + "aiReviewConfirmedContributorsOnly": { + "type": "boolean", + "nullable": true } }, "required": [ @@ -10362,6 +10366,10 @@ "defaultAllowed", "commandOverrides" ] + }, + "aiReviewConfirmedContributorsOnly": { + "type": "boolean", + "nullable": true } }, "required": [ @@ -10397,6 +10405,7 @@ "aiReviewProvider", "aiReviewModel", "aiReviewAllAuthors", + "aiReviewConfirmedContributorsOnly", "commandAuthorization" ] }, diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index aa825dcca3..4f3d66babf 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -350,15 +350,16 @@ gate: # off | advisory | block. Default: off. # advisory — posts AI review notes only. # block — a dual-model high-confidence consensus defect may become a - # blocker (confirmed-contributors only). + # blocker. mode: off # Use the maintainer's frontier model for the *advisory* write-up when a # provider key is configured. The consensus blocker always uses the free # Workers-AI pair, so BYOK never changes who can be blocked. # Bool. Default: false. byok: false - # Review every PR author when AI review is enabled. Keep false to spend - # model calls only on the engine's default eligible authors. + # Review every PR author when AI review is enabled. Only has an effect + # when `settings.aiReviewConfirmedContributorsOnly` (below) is also true -- + # otherwise every author is already reviewed by default. # Bool. Default: false. allAuthors: false # BYOK provider. anthropic | openai | null. Default: null (use the stored @@ -694,6 +695,13 @@ settings: # the safe-by-default add-only behavior. Bool. Default: false. hardGuardrailGlobsOverridesInvariants: false + # Restrict AI review (gate.aiReview.mode above) to confirmed Gittensor contributors only. Default: + # false -- AI review runs for EVERY PR author once aiReview.mode is not off, since security/quality + # review should not be weaker for a new or unconfirmed contributor's PR than for an established one's. + # Set true only if you deliberately want to bound AI spend to registered miners; gate.aiReview.allAuthors + # above can then still widen back out to every author within that narrowed mode. Bool. Default: false. + aiReviewConfirmedContributorsOnly: false + # Require a linked issue on every PR (dashboard equivalent of the toggle that # can auto-promote gate.linkedIssue to block). Bool. Default: false. requireLinkedIssue: false diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index a41e5298d6..002cd6908f 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -387,6 +387,7 @@ export type FocusManifestSettings = Partial< | "aiReviewProvider" | "aiReviewModel" | "aiReviewAllAuthors" + | "aiReviewConfirmedContributorsOnly" | "closeOwnerAuthors" | "autoLabelEnabled" | "typeLabelsEnabled" @@ -1857,7 +1858,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[], } const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"] as const, warnings); if (publicSurface !== null) out.publicSurface = publicSurface; - for (const key of ["aiReviewByok", "aiReviewAllAuthors", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "agentPaused", "agentDryRun", "hardGuardrailGlobsOverridesInvariants"] as const) { + for (const key of ["aiReviewByok", "aiReviewAllAuthors", "aiReviewConfirmedContributorsOnly", "closeOwnerAuthors", "autoLabelEnabled", "typeLabelsEnabled", "badgeEnabled", "publicQualityMetrics", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "agentPaused", "agentDryRun", "hardGuardrailGlobsOverridesInvariants"] as const) { const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } diff --git a/packages/gittensory-engine/src/types/manifest-deps-types.ts b/packages/gittensory-engine/src/types/manifest-deps-types.ts index 0a04c20c52..7e71e283fa 100644 --- a/packages/gittensory-engine/src/types/manifest-deps-types.ts +++ b/packages/gittensory-engine/src/types/manifest-deps-types.ts @@ -282,12 +282,23 @@ export type RepositorySettings = { /** Config-as-code model override for the BYOK advisory write-up (e.g. "claude-3-5-sonnet-latest"). * `null` = use the key record's model, else a conservative per-provider default. */ aiReviewModel?: string | null | undefined; - /** Review EVERY PR's author, not only confirmed Gittensor contributors. The AI maintainer review is - * confirmed-contributor-gated by default (an AI-spend guard). When true the review runs for any author — - * intended for a self-host operator who wants real reviews on all PRs (incl. their own) and pays for the - * AI themselves. Default false — opt-in via `.gittensory.yml gate.aiReview.allAuthors`. Independent of - * `aiReviewMode`: `off` still means no AI; this only widens WHO an enabled review covers. */ + /** Review EVERY PR's author, not only confirmed Gittensor contributors. Only meaningful when + * {@link aiReviewConfirmedContributorsOnly} is also `true` (that field opts INTO confirmed-only + * scoping in the first place — see its own doc comment for the full invariant: AI review runs for + * every author by default, this pair of fields exists purely for a self-host operator who + * deliberately wants to bound AI spend to registered miners). Default false — opt-in via + * `.gittensory.yml gate.aiReview.allAuthors`. Independent of `aiReviewMode`: `off` still means no AI; + * this only widens WHO an enabled review covers, and only within confirmed-contributors-only mode. */ aiReviewAllAuthors: boolean; + /** Opt-in narrowing (config-as-code, self-host operator's own choice — see resolveAiReviewableAuthor + * in src/queue/ai-review-orchestration.ts for the full invariant and history, #orb-ai-review-always- + * review): by default (false/absent) AI review runs for EVERY author once `aiReviewMode !== "off"` — + * security/quality review is not a privilege reserved for confirmed Gittensor miners. Set this `true` + * only if you deliberately want to bound (paid) AI-review spend to confirmed contributors + whatever + * {@link aiReviewAllAuthors}/the `oss-anti-slop`+`block` pack combo widens back in — the ORIGINAL, + * pre-2026-07-14 default behavior, preserved here as an explicit opt-in rather than silently applied + * to everyone. */ + aiReviewConfirmedContributorsOnly?: boolean | null | undefined; /** Configured AI-reviewer confidence floor (0-1) for close calibration (#7). Under `aiReviewMode: block`, AI * defect findings remain blockers even when their confidence is below this floor; the floor is retained as * configurable context, not a manual-review downgrade. Config-as-code only — set via `.gittensory.yml diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index b5bc60efcf..b5106d3bdc 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -706,6 +706,7 @@ export const RepositorySettingsSchema = z aiReviewProvider: z.enum(["anthropic", "openai"]).nullable().optional(), aiReviewModel: z.string().nullable().optional(), aiReviewAllAuthors: z.boolean(), + aiReviewConfirmedContributorsOnly: z.boolean().nullable().optional(), aiReviewCloseConfidence: z.number().nullable().optional(), aiReviewLowConfidenceDisposition: z.enum(["one_shot", "hold_for_review", "advisory_only"]).nullable().optional(), aiReviewCombine: z.enum(["single", "consensus", "synthesis"]).nullable().optional(), @@ -917,6 +918,7 @@ export const RepoSettingsPreviewSchema = z aiReviewProvider: z.string().nullable(), aiReviewModel: z.string().nullable(), aiReviewAllAuthors: z.boolean(), + aiReviewConfirmedContributorsOnly: z.boolean().nullable(), commandAuthorization: z.object({ defaultAllowed: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])), commandOverrides: z.array( diff --git a/src/queue/ai-review-orchestration.ts b/src/queue/ai-review-orchestration.ts index 39c9933fbc..1f5800bb6c 100644 --- a/src/queue/ai-review-orchestration.ts +++ b/src/queue/ai-review-orchestration.ts @@ -194,6 +194,35 @@ export async function shouldStartAiReviewForAdvisory( return !reputationSkip; } +/** Whether a PR's author is eligible for AI review, given the repo's config (#orb-ai-review-always-review). + * + * INVARIANT: AI review runs for EVERY author by default once a maintainer has opted the repo into AI + * review at all (`aiReviewMode !== "off"`) -- security/quality review is not a privilege reserved for + * confirmed Gittensor miners. The original `confirmedContributor` gate here (#644) was about not + * applying MINER-SPECIFIC gate rules (e.g. a required linked issue) to a non-miner contributor's PR -- + * it was never meant to exempt a whole class of authors from AI-driven defect detection, and doing so + * by default left a real gap: an unconfirmed/new contributor's PR got LESS scrutiny than an established + * one's, backwards from what a security posture should look like. Only when a maintainer EXPLICITLY sets + * `aiReviewConfirmedContributorsOnly: true` (opt-in, never the default) does this narrow back down to + * the original confirmed-contributor-only behavior, for a self-host operator who deliberately wants to + * bound AI spend to registered miners. `aiReviewAllAuthors` keeps its original meaning as the + * widen-back-out lever *within* that narrowed mode, unchanged. + * + * Shared by both the "should we even start a review" check (shouldRequirePublicAiReviewForAdvisory) and + * runAiReviewForAdvisory's own execution-time guard, so the two can never drift out of sync the way two + * independently hand-duplicated copies of this same condition previously could. + */ +export function resolveAiReviewableAuthor( + settings: Pick & { + aiReviewAllAuthors?: boolean | null | undefined; + }, + confirmedContributor: boolean, +): boolean { + if (!settings.aiReviewConfirmedContributorsOnly) return true; + const packAllowsAnyAuthorBlockingReview = settings.gatePack === "oss-anti-slop" && settings.aiReviewMode === "block"; + return confirmedContributor || packAllowsAnyAuthorBlockingReview || Boolean(settings.aiReviewAllAuthors); +} + export function shouldRequirePublicAiReviewForAdvisory( env: Env, args: { @@ -205,13 +234,7 @@ export function shouldRequirePublicAiReviewForAdvisory( skipAiReview?: boolean | undefined; }, ): boolean { - const packAllowsAnyAuthorBlockingReview = - args.settings.gatePack === "oss-anti-slop" && - args.settings.aiReviewMode === "block"; - const reviewableAuthor = - args.confirmedContributor || - packAllowsAnyAuthorBlockingReview || - args.settings.aiReviewAllAuthors; + const reviewableAuthor = resolveAiReviewableAuthor(args.settings, args.confirmedContributor); if ( args.skipAiReview || args.settings.aiReviewMode === "off" || @@ -392,16 +415,11 @@ export async function runAiReviewForAdvisory( } | undefined > { - const packAllowsAnyAuthorBlockingReview = - args.settings.gatePack === "oss-anti-slop" && - args.settings.aiReviewMode === "block"; - // `aiReviewAllAuthors` (per-repo opt-in, default false) widens the AI-spend gate to EVERY author — a self-host - // operator who wants real reviews on all PRs (incl. their own / unconfirmed contributors) and pays for the AI - // themselves. Default false ⇒ the confirmed-contributor gate is byte-identical to today. - const reviewableAuthor = - args.confirmedContributor || - packAllowsAnyAuthorBlockingReview || - args.settings.aiReviewAllAuthors; + // See resolveAiReviewableAuthor's own doc comment for the full invariant: AI review runs for every + // author by default once aiReviewMode !== "off"; only an explicit aiReviewConfirmedContributorsOnly + // opt-in narrows this back down to confirmed-contributor-or-widened, for a self-host operator who + // deliberately wants to bound AI spend to registered miners. + const reviewableAuthor = resolveAiReviewableAuthor(args.settings, args.confirmedContributor); if ( args.mode === "paused" || args.settings.aiReviewMode === "off" || diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 70573960c8..da3a0905b9 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -219,6 +219,7 @@ export type RepoSettingsPreview = { aiReviewProvider: string | null; aiReviewModel: string | null; aiReviewAllAuthors: boolean; + aiReviewConfirmedContributorsOnly: boolean; commandAuthorization: { defaultAllowed: CommandAuthorizationRole[]; commandOverrides: Array<{ command: string; allowedRoles: CommandAuthorizationRole[] }>; @@ -353,6 +354,7 @@ export function buildRepoSettingsPreview(args: { aiReviewProvider: settings.aiReviewProvider ?? null, aiReviewModel: settings.aiReviewModel ?? null, aiReviewAllAuthors: settings.aiReviewAllAuthors, + aiReviewConfirmedContributorsOnly: settings.aiReviewConfirmedContributorsOnly ?? false, commandAuthorization: summarizeCommandAuthorizationPolicy(settings.commandAuthorization), }, commandAuthorizationPreview, diff --git a/src/types.ts b/src/types.ts index bd3546754f..7de45289fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -859,12 +859,23 @@ export type RepositorySettings = { /** Config-as-code model override for the BYOK advisory write-up (e.g. "claude-3-5-sonnet-latest"). * `null` = use the key record's model, else a conservative per-provider default. */ aiReviewModel?: string | null | undefined; - /** Review EVERY PR's author, not only confirmed Gittensor contributors. The AI maintainer review is - * confirmed-contributor-gated by default (an AI-spend guard). When true the review runs for any author — - * intended for a self-host operator who wants real reviews on all PRs (incl. their own) and pays for the - * AI themselves. Default false — opt-in via `.gittensory.yml gate.aiReview.allAuthors`. Independent of - * `aiReviewMode`: `off` still means no AI; this only widens WHO an enabled review covers. */ + /** Review EVERY PR's author, not only confirmed Gittensor contributors. Only meaningful when + * {@link aiReviewConfirmedContributorsOnly} is also `true` (that field opts INTO confirmed-only + * scoping in the first place — see its own doc comment for the full invariant: AI review runs for + * every author by default, this pair of fields exists purely for a self-host operator who + * deliberately wants to bound AI spend to registered miners). Default false — opt-in via + * `.gittensory.yml gate.aiReview.allAuthors`. Independent of `aiReviewMode`: `off` still means no AI; + * this only widens WHO an enabled review covers, and only within confirmed-contributors-only mode. */ aiReviewAllAuthors: boolean; + /** Opt-in narrowing (config-as-code, self-host operator's own choice — see resolveAiReviewableAuthor + * in src/queue/ai-review-orchestration.ts for the full invariant and history, #orb-ai-review-always- + * review): by default (false/absent) AI review runs for EVERY author once `aiReviewMode !== "off"` — + * security/quality review is not a privilege reserved for confirmed Gittensor miners. Set this `true` + * only if you deliberately want to bound (paid) AI-review spend to confirmed contributors + whatever + * {@link aiReviewAllAuthors}/the `oss-anti-slop`+`block` pack combo widens back in — the ORIGINAL, + * pre-2026-07-14 default behavior, preserved here as an explicit opt-in rather than silently applied + * to everyone. */ + aiReviewConfirmedContributorsOnly?: boolean | null | undefined; /** Configured AI-reviewer confidence floor (0-1) for close calibration (#7). Under `aiReviewMode: block`, AI * defect findings remain BLOCKERS even when their confidence is below this floor — the floor never turns a * real defect into a non-blocker on its own. What DOES vary below the floor is governed by the separate diff --git a/test/unit/ai-review-advisory.test.ts b/test/unit/ai-review-advisory.test.ts index acbee9a144..e65d77f499 100644 --- a/test/unit/ai-review-advisory.test.ts +++ b/test/unit/ai-review-advisory.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildAiReviewDiff, claimAiReviewLock, runAiReviewForAdvisory, shouldStartAiReviewForAdvisory } from "../../src/queue/processors"; +import { resolveAiReviewableAuthor } from "../../src/queue/ai-review-orchestration"; import { BEST_REVIEW_MODELS, INCOHERENT_DIFF_ASSESSMENT } from "../../src/services/ai-review"; import * as sentryModule from "../../src/selfhost/sentry"; import { upsertRepositoryAiKey } from "../../src/db/repositories"; @@ -82,6 +83,52 @@ function aiEnv(run: () => Promise, flags = true) { }); } +// INVARIANT (#orb-ai-review-always-review): AI review must be eligible to run for every author by +// default. This was the wrong way around before 2026-07-14 — an unconfirmed/new contributor's PR got +// LESS scrutiny than an established contributor's, backwards from a sane security posture. Only an +// explicit, opt-in `aiReviewConfirmedContributorsOnly: true` (a self-host operator's own deliberate +// choice to bound paid AI-review spend) may narrow this back down — and even then, `aiReviewAllAuthors` +// and the `oss-anti-slop`+`block` pack combo still widen it back out, exactly as before. +describe("resolveAiReviewableAuthor invariant (#orb-ai-review-always-review)", () => { + const gittensorAdvisory = { gatePack: "gittensor", aiReviewMode: "advisory" } as const; + + it("DEFAULT (no aiReviewConfirmedContributorsOnly set): every author is reviewable, confirmed or not", () => { + expect(resolveAiReviewableAuthor(gittensorAdvisory, true)).toBe(true); + expect(resolveAiReviewableAuthor(gittensorAdvisory, false)).toBe(true); + }); + + it("DEFAULT holds even under the strictest pack/mode combo (oss-anti-slop + off) and explicit aiReviewAllAuthors: false", () => { + expect(resolveAiReviewableAuthor({ gatePack: "oss-anti-slop", aiReviewMode: "off", aiReviewAllAuthors: false }, false)).toBe(true); + }); + + it("REGRESSION: aiReviewConfirmedContributorsOnly: null/undefined behaves exactly like false (never narrows)", () => { + expect(resolveAiReviewableAuthor({ ...gittensorAdvisory, aiReviewConfirmedContributorsOnly: null }, false)).toBe(true); + expect(resolveAiReviewableAuthor({ ...gittensorAdvisory, aiReviewConfirmedContributorsOnly: undefined }, false)).toBe(true); + }); + + it("opt-in narrowing (aiReviewConfirmedContributorsOnly: true): unconfirmed author with no widening is NOT reviewable", () => { + expect(resolveAiReviewableAuthor({ ...gittensorAdvisory, aiReviewConfirmedContributorsOnly: true }, false)).toBe(false); + }); + + it("opt-in narrowing: a confirmed contributor is always reviewable regardless of pack/mode", () => { + expect(resolveAiReviewableAuthor({ ...gittensorAdvisory, aiReviewConfirmedContributorsOnly: true }, true)).toBe(true); + expect(resolveAiReviewableAuthor({ gatePack: "oss-anti-slop", aiReviewMode: "off", aiReviewConfirmedContributorsOnly: true }, true)).toBe(true); + }); + + it("opt-in narrowing: aiReviewAllAuthors still widens back out to every author", () => { + expect(resolveAiReviewableAuthor({ ...gittensorAdvisory, aiReviewConfirmedContributorsOnly: true, aiReviewAllAuthors: true }, false)).toBe(true); + }); + + it("opt-in narrowing: the oss-anti-slop + block pack combo still widens back out to every author", () => { + expect(resolveAiReviewableAuthor({ gatePack: "oss-anti-slop", aiReviewMode: "block", aiReviewConfirmedContributorsOnly: true }, false)).toBe(true); + }); + + it("opt-in narrowing: the pack-widen requires BOTH oss-anti-slop AND block — either alone stays narrowed", () => { + expect(resolveAiReviewableAuthor({ gatePack: "oss-anti-slop", aiReviewMode: "advisory", aiReviewConfirmedContributorsOnly: true }, false)).toBe(false); + expect(resolveAiReviewableAuthor({ gatePack: "gittensor", aiReviewMode: "block", aiReviewConfirmedContributorsOnly: true }, false)).toBe(false); + }); +}); + 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 }; @@ -90,15 +137,32 @@ describe("shouldStartAiReviewForAdvisory", () => { 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); + // INVARIANT (#orb-ai-review-always-review): an unconfirmed contributor is reviewable by DEFAULT -- + // see the dedicated resolveAiReviewableAuthor describe block above for the exhaustive branch coverage. + await expect(shouldStartAiReviewForAdvisory(enabledEnv(), { ...base, confirmedContributor: false })).resolves.toBe(true); + // Opt-in narrowing end to end: aiReviewConfirmedContributorsOnly: true with no widening blocks an + // unconfirmed author through the REAL shouldStartAiReviewForAdvisory path, not just the pure helper. await expect( shouldStartAiReviewForAdvisory(enabledEnv(), { ...base, - settings: { aiReviewMode: "advisory", gatePack: "gittensor", aiReviewAllAuthors: true } as RepositorySettings, + settings: { aiReviewMode: "advisory", gatePack: "gittensor", aiReviewConfirmedContributorsOnly: true } as RepositorySettings, + confirmedContributor: false, + }), + ).resolves.toBe(false); + await expect( + shouldStartAiReviewForAdvisory(enabledEnv(), { + ...base, + settings: { aiReviewMode: "advisory", gatePack: "gittensor", aiReviewConfirmedContributorsOnly: true, aiReviewAllAuthors: true } as RepositorySettings, + confirmedContributor: false, + }), + ).resolves.toBe(true); + await expect( + shouldStartAiReviewForAdvisory(enabledEnv(), { + ...base, + settings: { aiReviewMode: "block", gatePack: "oss-anti-slop", aiReviewConfirmedContributorsOnly: true } as RepositorySettings, confirmedContributor: false, }), ).resolves.toBe(true); - 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); @@ -232,20 +296,26 @@ describe("runAiReviewForAdvisory", () => { expect(usage?.model).toBe("anthropic:claude-sonnet-4-6->ollama:llama3.1"); }); - it("no-ops for a non-confirmed contributor under the gittensor pack and when there is no head SHA", async () => { + it("runs for a non-confirmed contributor by DEFAULT (#orb-ai-review-always-review), no-ops only when there is no head SHA", async () => { const env = aiEnv(async () => ({ response: defectJson() })); const base = { mode: "live" as const, settings: { aiReviewMode: "block", gatePack: "gittensor" } as RepositorySettings, repoFullName: "acme/widgets", pr, author: "alice" }; - expect(await runAiReviewForAdvisory(env, { ...base, advisory: advisory(), confirmedContributor: false })).toBeUndefined(); + expect(await runAiReviewForAdvisory(env, { ...base, advisory: advisory(), confirmedContributor: false })).not.toBeUndefined(); const noSha = advisory(); delete (noSha as Partial).headSha; expect(await runAiReviewForAdvisory(env, { ...base, advisory: noSha, confirmedContributor: true })).toBeUndefined(); }); - it("runs a blocking AI review for a non-confirmed contributor under oss-anti-slop", async () => { + it("opt-in narrowing (aiReviewConfirmedContributorsOnly: true): no-ops for a non-confirmed contributor under the gittensor pack", async () => { + const env = aiEnv(async () => ({ response: defectJson() })); + const base = { mode: "live" as const, settings: { aiReviewMode: "block", gatePack: "gittensor", aiReviewConfirmedContributorsOnly: true } as RepositorySettings, repoFullName: "acme/widgets", pr, author: "alice" }; + expect(await runAiReviewForAdvisory(env, { ...base, advisory: advisory(), confirmedContributor: false })).toBeUndefined(); + }); + + it("opt-in narrowing: a blocking AI review still runs for a non-confirmed contributor under oss-anti-slop + block (pack-widen)", async () => { const adv = advisory(); const result = await runAiReviewForAdvisory(aiEnv(async () => ({ response: defectJson() })), { mode: "live", - settings: { aiReviewMode: "block", gatePack: "oss-anti-slop" } as RepositorySettings, + settings: { aiReviewMode: "block", gatePack: "oss-anti-slop", aiReviewConfirmedContributorsOnly: true } as RepositorySettings, advisory: adv, repoFullName: "acme/widgets", pr, @@ -256,15 +326,13 @@ describe("runAiReviewForAdvisory", () => { expect(result?.notes).toContain("Likely crash."); }); - it("runs the review for a non-confirmed contributor when aiReviewAllAuthors is on (per-repo opt-in)", async () => { - // The default confirmed-contributor AI-spend gate (line 87 above) returns undefined for an unconfirmed - // author; aiReviewAllAuthors flips that to run the review for EVERY author (a self-host operator paying for - // their own AI). gittensor pack + advisory mode, so neither packAllowsAnyAuthorBlockingReview nor confirmation - // is what lets it through — only the new flag. + it("opt-in narrowing: aiReviewAllAuthors still widens the review back to a non-confirmed contributor (per-repo opt-in)", async () => { + // With aiReviewConfirmedContributorsOnly: true, gittensor pack + advisory mode means neither + // packAllowsAnyAuthorBlockingReview nor confirmation is what lets this through — only aiReviewAllAuthors. const adv = advisory(); const result = await runAiReviewForAdvisory(aiEnv(async () => ({ response: notesOnlyJson() })), { mode: "live", - settings: { aiReviewMode: "advisory", gatePack: "gittensor", aiReviewAllAuthors: true , closeOwnerAuthors: false} as RepositorySettings, + settings: { aiReviewMode: "advisory", gatePack: "gittensor", aiReviewConfirmedContributorsOnly: true, aiReviewAllAuthors: true, closeOwnerAuthors: false } as RepositorySettings, advisory: adv, repoFullName: "acme/widgets", pr, diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index d0fe67a92d..6dc50e0aca 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -343,6 +343,7 @@ describe(".loopover.yml.example field-exhaustiveness (#1670)", () => { autoCloseExemptLogins: "autoCloseExemptLogins:", hardGuardrailGlobs: "hardGuardrailGlobs:", hardGuardrailGlobsOverridesInvariants: "hardGuardrailGlobsOverridesInvariants:", + aiReviewConfirmedContributorsOnly: "aiReviewConfirmedContributorsOnly:", manualReviewLabel: "manualReviewLabel:", readyToMergeLabel: "readyToMergeLabel:", changesRequestedLabel: "changesRequestedLabel:", diff --git a/test/unit/queue-3.test.ts b/test/unit/queue-3.test.ts index bfd7ad3e08..780c02a107 100644 --- a/test/unit/queue-3.test.ts +++ b/test/unit/queue-3.test.ts @@ -1213,7 +1213,7 @@ describe("queue processors", () => { expect(seen.comments.some((c) => c.includes("blocked from contributing"))).toBe(true); }); - it("screenshot-table gate (#2006): an in-scope contributor PR missing a before/after table is closed deterministically with NO AI call and no merit merge", async () => { + it("screenshot-table gate (#2006): an in-scope contributor PR missing a before/after table is closed deterministically regardless of the (unrelated, still-running) AI review and no merit merge", async () => { let aiCalls = 0; const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), @@ -1269,8 +1269,11 @@ describe("queue processors", () => { }, }); - // Deterministic gate: closed, and the AI was NEVER called for the disposition. - expect(aiCalls).toBe(0); + // Deterministic gate: closed regardless of the AI review's own (unrelated, advisory-only) verdict -- + // AI review runs for this author too (#orb-ai-review-always-review: it is no longer gated on + // confirmed-contributor status), but the screenshot-table gate's close decision doesn't wait on or + // depend on it either way. + expect(aiCalls).toBe(1); expect(seen.closed).toBe(true); const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); expect(closeAudit?.n).toBeGreaterThanOrEqual(1); @@ -2378,8 +2381,10 @@ describe("queue processors", () => { }, }); - // Deterministic gate: closed + labeled (with the configured label), and the AI was NEVER called. - expect(aiCalls).toBe(0); + // Deterministic gate: closed + labeled (with the configured label) regardless of the AI review's own + // (unrelated, advisory-only) verdict -- AI review runs for this author too (#orb-ai-review-always-review: + // it is no longer gated on confirmed-contributor status), but the cap's close decision doesn't wait on it. + expect(aiCalls).toBe(1); expect(seen.closed).toBe(true); expect(seen.labels).toContain("spam-cap"); const closeAudit = await env.DB.prepare("select count(*) as n from audit_events where event_type = 'agent.action.close'").first<{ n: number }>(); diff --git a/test/unit/settings-preview.test.ts b/test/unit/settings-preview.test.ts index ee6d4c2712..4a33f6cfd3 100644 --- a/test/unit/settings-preview.test.ts +++ b/test/unit/settings-preview.test.ts @@ -190,6 +190,18 @@ describe("buildRepoSettingsPreview", () => { expect(() => RepoSettingsPreviewSchema.parse(preview)).not.toThrow(); }); + it("passes through an explicit aiReviewConfirmedContributorsOnly instead of falling back to false", () => { + const preview = buildRepoSettingsPreview({env: {}, + ...base, + settings: settings({ aiReviewConfirmedContributorsOnly: true }), + installation: healthyInstall, + sample: { authorLogin: "miner", minerStatus: "confirmed" }, + }); + + expect(preview.settings.aiReviewConfirmedContributorsOnly).toBe(true); + expect(() => RepoSettingsPreviewSchema.parse(preview)).not.toThrow(); + }); + it("uses safe defaults for an empty sample preview", () => { const preview = buildRepoSettingsPreview({env: {}, ...base, settings: settings(), installation: healthyInstall, sample: {} }); expect(preview.sample).toMatchObject({ authorLogin: "sample-contributor", authorType: "User", authorAssociation: "NONE", minerStatus: "confirmed", title: "Sample pull request" });