From 24ab6af8ec035f1931225363ccf0a60b3684c58a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:48:24 -0700 Subject: [PATCH] feat(selfhost): per-repo feature config + all-authors AI review + RAG embed stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciles bb90b64e onto current main. Per-repo `features.{rag,reputation,unifiedComment,safety}` overrides (manifest → GITTENSORY_REVIEW_REPOS allowlist default, byte-identical when unset), the `aiReviewAllAuthors` opt-in (review every author, not only confirmed contributors), and a dedicated RAG embed provider (AI_EMBED_BASE_URL) so the review chain stays frontier-only. Resolution notes: KEPT main's #1462 AI-review cache, #1425 contributor blacklist, and inline-comments on the review path; DROPPED the batch's head-SHA dedup (ai-review-dedup) as superseded by #1462. Migration renumbered 0072→0075 (0072 was taken by contributor_blacklist on main). --- migrations/0075_ai_review_all_authors.sql | 5 + src/api/routes.ts | 21 +++ src/db/repositories.ts | 5 + src/db/schema.ts | 1 + src/env.d.ts | 5 + src/queue/processors.ts | 135 ++++++++++++------ src/review/adapters.ts | 5 +- src/review/cutover-gate.ts | 22 +++ src/review/feature-activation.ts | 57 ++++++++ src/selfhost/ai.ts | 24 +++- src/selfhost/private-config.ts | 59 ++++++-- src/server.ts | 26 +++- src/services/ai-review.ts | 21 +-- src/signals/focus-manifest-loader.ts | 3 +- src/signals/focus-manifest.ts | 74 +++++++++- src/signals/settings-preview.ts | 2 + src/types.ts | 6 + test/integration/api.test.ts | 24 ++++ test/unit/ai-review-advisory.test.ts | 43 ++++++ test/unit/cutover-gate.test.ts | 16 ++- test/unit/feature-activation.test.ts | 76 ++++++++++ test/unit/focus-manifest.test.ts | 59 +++++++- test/unit/maintainer-activation.test.ts | 1 + test/unit/policy-sanitizer.test.ts | 1 + test/unit/private-config.test.ts | 64 +++++++-- test/unit/rag-index.test.ts | 51 ++++++- test/unit/registration-readiness.test.ts | 1 + test/unit/repo-policy-readiness.test.ts | 1 + .../repository-settings-enforcement.test.ts | 1 + test/unit/review-adapters.test.ts | 20 +++ test/unit/routes-ai-byok.test.ts | 5 +- .../self-dogfood-registration-pack.test.ts | 1 + test/unit/selfhost-ai.test.ts | 57 ++++++-- test/unit/settings-preview.test.ts | 1 + test/unit/signals-coverage.test.ts | 1 + test/unit/signals-v2.test.ts | 1 + test/unit/signals.test.ts | 6 + test/unit/unified-comment-parity.test.ts | 1 + 38 files changed, 800 insertions(+), 102 deletions(-) create mode 100644 migrations/0075_ai_review_all_authors.sql create mode 100644 src/review/feature-activation.ts create mode 100644 test/unit/feature-activation.test.ts diff --git a/migrations/0075_ai_review_all_authors.sql b/migrations/0075_ai_review_all_authors.sql new file mode 100644 index 0000000000..200698b27a --- /dev/null +++ b/migrations/0075_ai_review_all_authors.sql @@ -0,0 +1,5 @@ +-- AI review for all authors (per-repo opt-in). The AI maintainer review is confirmed-contributor-gated by +-- default (an AI-spend guard, see runAiReviewForAdvisory). `ai_review_all_authors` lets a self-host operator +-- run the review for EVERY PR's author — intended for an operator who wants real reviews on all PRs (incl. their +-- own) and pays for the AI themselves. Default 0 (off) — additive, existing repos are byte-identical. +ALTER TABLE repository_settings ADD COLUMN ai_review_all_authors INTEGER NOT NULL DEFAULT 0; diff --git a/src/api/routes.ts b/src/api/routes.ts index feb5c68610..21d3ca5e3a 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -233,6 +233,7 @@ import { buildRepoOutcomeCalibration } from "../services/outcome-calibration"; import { loadGatePrecisionReport } from "../services/gate-precision"; import { computeOpsStats, isOpsEnabled } from "../review/ops-wire"; import { computeParityReadiness, isParityAuditEnabled } from "../review/parity-wire"; +import { isRagEnabled } from "../review/rag-wire"; import { getPublicStats, isPublicStatsEnabled } from "../review/public-stats"; import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard"; import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics"; @@ -619,6 +620,7 @@ const repositorySettingsSchema = z.object({ aiReviewByok: z.boolean().default(false), aiReviewProvider: z.enum(["anthropic", "openai"]).nullable().optional(), aiReviewModel: z.string().trim().min(1).max(120).nullable().optional(), + aiReviewAllAuthors: z.boolean().default(false), autoLabelEnabled: z.boolean().default(true), gittensorLabel: z.string().trim().min(1).max(50).default("gittensor"), blacklistLabel: z.string().trim().min(1).max(50).default("slop"), @@ -710,6 +712,7 @@ const repositoryAiReviewSchema = z.object({ byok: z.boolean().default(false), provider: z.enum(["anthropic", "openai"]).nullable().optional(), model: z.string().trim().min(1).max(120).nullable().optional(), + allAuthors: z.boolean().default(false), }); const contributorIssueDraftGenerateSchema = z.object({ @@ -2246,6 +2249,7 @@ export function createApp() { aiReviewByok: parsed.data.byok, aiReviewProvider: parsed.data.provider, aiReviewModel: parsed.data.model, + aiReviewAllAuthors: parsed.data.allAuthors, }); // getRepositorySettings normalizes these to a concrete value or null (never undefined). return c.json({ @@ -2253,6 +2257,7 @@ export function createApp() { aiReviewByok: updated.aiReviewByok, aiReviewProvider: updated.aiReviewProvider ?? null, aiReviewModel: updated.aiReviewModel ?? null, + aiReviewAllAuthors: updated.aiReviewAllAuthors, }); }); @@ -3056,6 +3061,21 @@ export function createApp() { return c.json({ ok: true, status: "queued" }, 202); }); + // Operator-facing RAG (re)index trigger for a self-host maintainer. Bearer-gated by the `/v1/internal/*` + // middleware (INTERNAL_JOB_TOKEN). With NO body it enqueues the fan-out (re-indexes every RAG-active configured + + // registered repo); with `{ "repoFullName": "owner/repo" }` it indexes just that repo. Either way the job is + // gated downstream by convergedFeatureActive, so a repo where RAG is off is a no-op. 404 when RAG is globally off + // so the endpoint doesn't exist on a deploy that isn't running RAG. This is how an operator adds/indexes a new + // repo on demand instead of waiting for the 6-hourly cron. + app.post("/v1/internal/jobs/rag-index", async (c) => { + if (!isRagEnabled(c.env)) return c.json({ error: "not_found" }, 404); + const body = (await c.req.json().catch(() => ({}))) as { repoFullName?: unknown }; + const repoFullName = typeof body?.repoFullName === "string" && body.repoFullName.trim().length > 0 ? body.repoFullName.trim() : undefined; + const message: JobMessage = { type: "rag-index-repo", requestedBy: "api", ...(repoFullName ? { repoFullName } : {}) }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", scope: repoFullName ?? "all-configured-repos" }, 202); + }); + app.post("/v1/internal/jobs/refresh-registry/run", async (c) => { return c.json(await refreshRegistry(c.env)); }); @@ -3365,6 +3385,7 @@ export function createApp() { aiReviewByok: parsed.data.aiReviewByok, aiReviewProvider: parsed.data.aiReviewProvider, aiReviewModel: parsed.data.aiReviewModel, + aiReviewAllAuthors: parsed.data.aiReviewAllAuthors, autoLabelEnabled: parsed.data.autoLabelEnabled, gittensorLabel: parsed.data.gittensorLabel, blacklistLabel: parsed.data.blacklistLabel, diff --git a/src/db/repositories.ts b/src/db/repositories.ts index bee97c2fdf..a2772045fb 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -431,6 +431,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise aiReviewByok: false, aiReviewProvider: null, aiReviewModel: null, + aiReviewAllAuthors: false, autoLabelEnabled: true, gittensorLabel: "gittensor", blacklistLabel: "slop", @@ -473,6 +474,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise aiReviewByok: row.aiReviewByok, aiReviewProvider: normalizeAiReviewProvider(row.aiReviewProvider), aiReviewModel: row.aiReviewModel ?? null, + aiReviewAllAuthors: row.aiReviewAllAuthors, autoLabelEnabled: row.autoLabelEnabled, gittensorLabel: row.gittensorLabel, blacklistLabel: row.blacklistLabel, @@ -519,6 +521,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial { - const repositories = (await listRepositories(env)).filter( - (repo) => repo.isRegistered && isConvergenceRepoAllowed(env, repo.fullName), + // Candidate repos = the webhook-REGISTERED repos UNION the maintainer's CONFIGURED repos (GITTENSORY_REVIEW_REPOS). + // The union is the fix for the brokered self-host: a maintainer's repos are is_registered=0 (never went through the + // registration webhook), so a registered-only fan-out never indexed them — leaving reviews without codebase context. + // Deduped case-insensitively (a repo can be both registered AND configured). Each is then filtered by whether RAG is + // active for it (`features.rag` override → GITTENSORY_REVIEW_REPOS allowlist default), so nothing extra is indexed. + const byKey = new Map(); + for (const repo of (await listRepositories(env)).filter( + (r) => r.isRegistered, + )) + byKey.set(repo.fullName.toLowerCase(), repo.fullName); + for (const fullName of listConvergenceRepos(env)) + byKey.set(fullName.toLowerCase(), fullName); + const candidates = [...byKey.values()]; + const ragActiveByRepo = await Promise.all( + candidates.map((fullName) => convergedFeatureActive(env, fullName, "rag")), ); + const repositories = candidates.filter((_, index) => ragActiveByRepo[index]); await Promise.all( - repositories.map((repo, index) => { + repositories.map((fullName, index) => { const message: JobMessage = { type: "rag-index-repo", requestedBy, - repoFullName: repo.fullName, + repoFullName: fullName, }; const delaySeconds = Math.min(index * 30, 900); return delaySeconds > 0 @@ -879,8 +898,7 @@ async function maybeEnqueueRagReindexForMergedPr( action: string | undefined, mergedAt: string | null | undefined, ): Promise { - if (!isRagEnabled(env) || !isConvergenceRepoAllowed(env, repoFullName)) - return; + if (!(await convergedFeatureActive(env, repoFullName, "rag"))) return; // A PR that merged: closed action + a merged_at timestamp. (A closed-unmerged PR changed nothing on the base.) if (!PR_GATE_CLOSED_ACTIONS.has(action ?? "") || !mergedAt) return; const files = await listPullRequestFiles(env, repoFullName, pullNumber); @@ -1900,6 +1918,9 @@ async function maybeCaptureOnDeploymentStatus( const preview = deploymentStatusToPreview( payload as unknown as DeploymentStatusPayload, ); + // The deployment-status re-review just refreshes the visual capture; the capture site itself honors the per-repo + // `features.screenshots` override, so this trigger stays on the convergence allowlist (a re-review for a repo + // with screenshots disabled simply produces no capture — same outcome, no incoherence). if (preview && isConvergenceRepoAllowed(env, repoFullName)) { await reReviewStoredPullRequest( env, @@ -2986,11 +3007,13 @@ async function processGitHubWebhook( // outcome is derived ONLY from the PR's realized terminal state + the gate verdict (no PR content); // nothing is ever surfaced publicly. Flag-OFF (default) is an immediate no-op (nothing recorded), so the // path is byte-identical. Best-effort: a record failure must never affect the gate or the public surface. - const reputationOutcome = - isReputationEnabled(env) && - isConvergenceRepoAllowed(env, repoFullName) - ? reputationOutcomeFromTerminalState(pr, payload.pull_request, gate) - : undefined; + const reputationOutcome = (await convergedFeatureActive( + env, + repoFullName, + "reputation", + )) + ? reputationOutcomeFromTerminalState(pr, payload.pull_request, gate) + : undefined; if (reputationOutcome) { await recordReputationOutcome(env, { project: repoFullName, @@ -3422,9 +3445,16 @@ export async function runAiReviewForAdvisory( 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; if ( args.settings.aiReviewMode === "off" || - (!args.confirmedContributor && !packAllowsAnyAuthorBlockingReview) || + !reviewableAuthor || !args.advisory.headSha ) return undefined; @@ -3433,6 +3463,27 @@ export async function runAiReviewForAdvisory( // feature's global flag below. Empty/unset allowlist → false → every converged branch here is unreachable // (byte-identical to today) regardless of the global flags. const convergedRepoAllowed = isConvergenceRepoAllowed(env, args.repoFullName); + // Per-repo feature overrides (phase 2): reputation + RAG honor the container-private `.gittensory.yml` `features:` + // block, falling back to the `convergedRepoAllowed` allowlist when unset (byte-identical default). The (cached) + // manifest is loaded once and shared, and ONLY when at least one of the two features is globally enabled — so a + // deploy with both flags off does no extra read (preserves the no-op default). Grounding deliberately stays on + // `convergedRepoAllowed` here so it remains coherent with the disposition-side CI-refutation gate (#deferred). + const featureManifest = + isReputationEnabled(env) || isRagEnabled(env) + ? await loadRepoFocusManifest(env, args.repoFullName).catch(() => null) + : null; + const reputationActive = resolveConvergedFeature( + env, + featureManifest, + "reputation", + args.repoFullName, + ); + const ragActive = resolveConvergedFeature( + env, + featureManifest, + "rag", + args.repoFullName, + ); // Reputation anti-abuse (convergence, flag-gated by GITTENSORY_REVIEW_REPUTATION). Extends the AI-spend gate above: // an INTERNAL low-reputation / burst / new submitter is downgraded to a DETERMINISTIC-ONLY review — the // (paid) AI neurons are skipped here exactly as they are for an unconfirmed contributor, so a serial abuser @@ -3441,8 +3492,7 @@ export async function runAiReviewForAdvisory( // read, no new branch) → the AI-spend gate is byte-identical to today. Fail-safe (the read degrades to // neutral → false on any error). if ( - isReputationEnabled(env) && - convergedRepoAllowed && + reputationActive && (await shouldSkipAiForReputation(env, { project: args.repoFullName, submitter: args.author, @@ -3503,20 +3553,19 @@ export async function runAiReviewForAdvisory( // semantically related to the changed files and append them as additive reference context — exactly like // grounding. Flag-OFF (default) → NO new branch: no adapter use, no vector query, and `ragContext` is left // undefined so the prompt is byte-identical to today. Fully fail-safe (a missing/cold index degrades to ""). - const ragContext = - isRagEnabled(env) && convergedRepoAllowed - ? await buildReviewRagContext(env, { - repoFullName: args.repoFullName, - title: args.pr.title, - files: files.map((file) => ({ - path: file.path, - patch: - typeof file.payload?.patch === "string" - ? file.payload.patch - : undefined, - })), - }) - : undefined; + const ragContext = ragActive + ? await buildReviewRagContext(env, { + repoFullName: args.repoFullName, + title: args.pr.title, + files: files.map((file) => ({ + path: file.path, + patch: + typeof file.payload?.patch === "string" + ? file.payload.patch + : undefined, + })), + }) + : undefined; // Review-enrichment (#1472, flag-gated by GITTENSORY_REVIEW_ENRICHMENT + REES_URL). POST the PR to the external // REES for the heavy/external analysis the reviewer can't run (dependency CVEs, secrets, license/EOL/supply-chain); // its public-safe brief splices into the prompt next to grounding + RAG. Flag-OFF (default) → no call, no branch, @@ -3888,13 +3937,15 @@ async function maybePublishPrPublicSurface( // a dry-run / pause / global-freeze publishes NOTHING (check-run, comment, label) — the gate verdict is still // computed + returned for the disposition logic, the writes are just suppressed + audited. (#dry-run-chokepoint) const mode = await resolveRepoActionMode(env, settings); - // Per-repo cutover gate (GITTENSORY_REVIEW_REPOS): the unified converged comment renders for THIS repo - // only when it is allowlisted AND the global GITTENSORY_REVIEW_UNIFIED_COMMENT flag is ON. Computed once and ANDed into - // both unified-comment sites below (closed/skipped + open). Empty/unset allowlist → false → both sites keep - // the LEGACY panel byte-identical for every repo regardless of GITTENSORY_REVIEW_UNIFIED_COMMENT. - const unifiedCommentAllowed = - isUnifiedReviewCommentEnabled(env) && - isConvergenceRepoAllowed(env, repoFullName); + // Per-repo feature override (phase 2): the unified converged comment renders for THIS repo when the global + // GITTENSORY_REVIEW_UNIFIED_COMMENT kill-switch is ON and the repo's container-private `.gittensory.yml` + // `features.unifiedComment` opts in — falling back to the GITTENSORY_REVIEW_REPOS allowlist when the manifest + // says nothing (byte-identical default). Computed once and used by both unified-comment sites below. + const unifiedCommentAllowed = await convergedFeatureActive( + env, + repoFullName, + "unifiedComment", + ); // `settings` is the EFFECTIVE config (`.gittensory.yml` > DB > defaults), resolved by the caller via // resolveRepositorySettings — so gate on/off and every blocker mode already reflect the repo's config // file. The gate verdict is the same for every author; confirmedContributor feeds only on-chain scoring. diff --git a/src/review/adapters.ts b/src/review/adapters.ts index a23a9a67f2..0dafe36677 100644 --- a/src/review/adapters.ts +++ b/src/review/adapters.ts @@ -69,6 +69,9 @@ export function reviewInferenceAdapter(ai: Ai): InferenceAdapter { export function createReviewAdapters(env: Env): RagInfra { const infra: RagInfra = { storage: reviewStorageAdapter(env) }; if (env.VECTORIZE) infra.vector = reviewVectorAdapter(env.VECTORIZE); - if (env.AI) infra.inference = reviewInferenceAdapter(env.AI); + // Embeddings use the DEDICATED embed provider (env.AI_EMBED) when configured — keeping the review chat chain + // frontier-only — and fall back to env.AI otherwise (byte-identical to before). + const embedAi = env.AI_EMBED ?? env.AI; + if (embedAi) infra.inference = reviewInferenceAdapter(embedAi); return infra; } diff --git a/src/review/cutover-gate.ts b/src/review/cutover-gate.ts index e387e5ab9e..f277927b05 100644 --- a/src/review/cutover-gate.ts +++ b/src/review/cutover-gate.ts @@ -36,3 +36,25 @@ export function isConvergenceRepoAllowed(env: { GITTENSORY_REVIEW_REPOS?: string } return false; } + +/** + * The configured GITTENSORY_REVIEW_REPOS as a deduped list of "owner/repo" full-names (original case preserved, + * deduped case-insensitively, empty entries dropped). Empty when unset. + * + * Used to PROACTIVELY index a self-host maintainer's repos for RAG even when they were never registered via a + * webhook (the brokered model leaves is_registered=0), so a maintainer's whole repo set is pre-indexed for + * codebase-aware reviews instead of waiting for a cold first-PR index. + */ +export function listConvergenceRepos(env: { GITTENSORY_REVIEW_REPOS?: string | undefined }): string[] { + const seen = new Set(); + const out: string[] = []; + for (const entry of (env.GITTENSORY_REVIEW_REPOS ?? "").split(",")) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(trimmed); + } + return out; +} diff --git a/src/review/feature-activation.ts b/src/review/feature-activation.ts new file mode 100644 index 0000000000..179ba40785 --- /dev/null +++ b/src/review/feature-activation.ts @@ -0,0 +1,57 @@ +// Per-repo activation resolver for the converged review features (phase 2 of the per-repo migration). +// +// Before: each feature ran when `isXEnabled(env)` (a global env flag) AND `isConvergenceRepoAllowed(env, repo)` +// (the GITTENSORY_REVIEW_REPOS allowlist) were both true — coarse, all-or-nothing per repo, and configured only +// via env. Now a self-host operator toggles features individually PER REPO in the container-private `.gittensory.yml` +// (`features:` block). The precedence, highest to lowest: +// 1. GLOBAL env flag (GITTENSORY_REVIEW_*) — a MASTER KILL-SWITCH. Off ⇒ the feature never runs anywhere, +// regardless of any per-repo override (so an operator keeps one deploy-wide off switch per feature). +// 2. Per-repo `features:` override — `true`/`false` forces the feature on/off for this repo. +// 3. `GITTENSORY_REVIEW_REPOS` allowlist — the back-compat DEFAULT when the manifest says nothing, so a repo +// that sets no `features:` block behaves exactly as it did before this change. +// +// `resolveConvergedFeature` is the pure core (takes the already-loaded manifest). `convergedFeatureActive` is the +// async convenience that loads the cached focus manifest itself — used at call sites that don't already hold one. +import { isConvergenceRepoAllowed } from "./cutover-gate"; +import { isRagEnabled } from "./rag-wire"; +import { isReputationEnabled } from "./reputation-wire"; +import { isSafetyEnabled } from "./safety"; +import { isUnifiedReviewCommentEnabled } from "./unified-comment-bridge"; +import type { ConvergedFeatureKey, FocusManifest } from "../signals/focus-manifest"; +import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; + +/** The master kill-switch (global env flag) for each converged feature, keyed by the manifest `features:` key. */ +const FEATURE_GLOBAL_FLAG: Record boolean> = { + rag: isRagEnabled, + reputation: isReputationEnabled, + unifiedComment: isUnifiedReviewCommentEnabled, + safety: isSafetyEnabled, +}; + +/** + * Resolve whether a converged feature is active for a repo, given the already-loaded manifest (or null). Pure + + * synchronous so it carries no I/O and is the single unit-tested place the precedence lives. Precedence: env + * kill-switch (off ⇒ false) → per-repo `features:` override → `GITTENSORY_REVIEW_REPOS` allowlist default. + */ +export function resolveConvergedFeature( + env: Env, + manifest: Pick | null | undefined, + feature: ConvergedFeatureKey, + repoFullName: string, +): boolean { + if (!FEATURE_GLOBAL_FLAG[feature](env)) return false; // master kill-switch + const override = manifest?.features?.[feature] ?? null; + if (override !== null) return override; // explicit per-repo on/off + return isConvergenceRepoAllowed(env, repoFullName); // back-compat allowlist default +} + +/** + * Async convenience: resolve a converged feature for a repo, loading the (cached) focus manifest internally. + * Short-circuits BEFORE the manifest load when the env kill-switch is off, so a globally-disabled feature pays + * no I/O. The manifest load is fail-safe (a read error degrades to null ⇒ the allowlist default applies). + */ +export async function convergedFeatureActive(env: Env, repoFullName: string, feature: ConvergedFeatureKey): Promise { + if (!FEATURE_GLOBAL_FLAG[feature](env)) return false; // no manifest load when globally off + const manifest = await loadRepoFocusManifest(env, repoFullName).catch(() => null); + return resolveConvergedFeature(env, manifest, feature, repoFullName); +} diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 9f58ae2188..853d7560c2 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -50,6 +50,20 @@ export function resolveEffort(configured: string | undefined): string { return VALID_EFFORTS.has(level) ? level : "high"; } +// Per-effort subprocess timeout (ms) for the subscription CLIs. A higher effort legitimately runs longer, so the +// old fixed 120s cap silently SIGKILLed a large max-effort review mid-generation (the review then degrades to +// nothing). These scale the ceiling with the effort dial; AI_TIMEOUT_MS overrides them outright. +const EFFORT_TIMEOUT_MS: Record = { low: 120_000, medium: 120_000, high: 240_000, xhigh: 360_000, max: 600_000 }; + +/** Resolve the subscription-CLI subprocess timeout (ms). An explicit `AI_TIMEOUT_MS` wins, clamped to a sane + * 30s–30min range so a typo can neither hang a worker nor cut a review off after a few seconds. Absent/invalid ⇒ + * it scales with the `AI_EFFORT` dial (resolveEffort always yields a known level, so the map lookup is total). */ +export function resolveCliTimeoutMs(env: Record): number { + const raw = Number(env.AI_TIMEOUT_MS); + if (Number.isFinite(raw) && raw > 0) return Math.min(1_800_000, Math.max(30_000, raw)); + return EFFORT_TIMEOUT_MS[resolveEffort(env.AI_EFFORT)]!; +} + /** OpenAI-compatible endpoint (Ollama's /v1, OpenAI, vLLM, LM Studio, …) — chat + embeddings. */ export function createOpenAiCompatibleAi(opts: { baseUrl: string; apiKey?: string | undefined; model?: string | undefined; embedModel?: string | undefined }): SelfHostAi { const base = opts.baseUrl.replace(/\/+$/, ""); @@ -198,6 +212,10 @@ async function defaultSpawn(): Promise { export function createClaudeCodeAi(parentEnv: Record, spawnImpl?: SpawnFn): SelfHostAi { return { async run(model, options) { + // Claude has no embeddings model (CLI or API), so REJECT an embed request and let the provider chain fall + // through to an embed-capable provider (ollama/openai-compatible). Without this throw the chain would treat + // claude's empty-prompt text answer as "success" and never reach the embed provider → RAG silently breaks. + if (options.text) throw new Error("claude_code_no_embed"); const token = parentEnv.CLAUDE_CODE_OAUTH_TOKEN; if (!token) throw new Error("claude_code_no_oauth_token"); const env = scrubBillableKeys(parentEnv); @@ -206,7 +224,7 @@ export function createClaudeCodeAi(parentEnv: Record const spawn = spawnImpl ?? (await defaultSpawn()); const claudeModel = resolveModel(configuredModel(parentEnv), model, "claude-sonnet-4-6"); const effort = resolveEffort(parentEnv.AI_EFFORT); - const { stdout, code } = await spawn("claude", ["--print", "--output-format", "json", "--model", claudeModel, "--permission-mode", "plan", "--effort", effort, "--disallowedTools", "Bash,Edit,Write,WebFetch,WebSearch"], { env, input: prompt, timeoutMs: 120_000 }); + const { stdout, code } = await spawn("claude", ["--print", "--output-format", "json", "--model", claudeModel, "--permission-mode", "plan", "--effort", effort, "--disallowedTools", "Bash,Edit,Write,WebFetch,WebSearch"], { env, input: prompt, timeoutMs: resolveCliTimeoutMs(parentEnv) }); if (code !== 0) throw new Error(`claude_code_exit_${code ?? "null"}`); const errStatus = claudeErrorStatus(stdout); if (errStatus) throw new Error(`claude_code_error_${errStatus}`); @@ -223,6 +241,8 @@ export function createClaudeCodeAi(parentEnv: Record export function createCodexAi(parentEnv: Record, spawnImpl?: SpawnFn): SelfHostAi { return { async run(model, options) { + // Codex is chat-only here — reject embed requests so the chain routes them to an embed-capable provider. + if (options.text) throw new Error("codex_no_embed"); const env = scrubBillableKeys(parentEnv); const prompt = toMessages(options).map((m) => m.content).join("\n\n"); const spawn = spawnImpl ?? (await defaultSpawn()); @@ -234,7 +254,7 @@ export function createCodexAi(parentEnv: Record, spa const args = ["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only"]; if (codexModel) args.push("--model", codexModel); args.push("--", prompt); - const { stdout, code } = await spawn("codex", args, { env, timeoutMs: 120_000 }); + const { stdout, code } = await spawn("codex", args, { env, timeoutMs: resolveCliTimeoutMs(parentEnv) }); if (code !== 0) throw new Error(`codex_exit_${code ?? "null"}`); const text = extractCliText(stdout); if (!text) throw new Error("codex_empty_output"); diff --git a/src/selfhost/private-config.ts b/src/selfhost/private-config.ts index 48f3d72d6f..4d099e5033 100644 --- a/src/selfhost/private-config.ts +++ b/src/selfhost/private-config.ts @@ -1,32 +1,63 @@ // Container-private per-repo config (self-host). A self-host operator mounts a directory at -// GITTENSORY_REPO_CONFIG_DIR and drops one `{owner}__{repo}.yml` file per repo; the focus-manifest loader reads -// it INSTEAD of fetching the public `.gittensory.yml`, so review policy (gate, autonomy, labels, model/effort) is -// configured PRIVATELY and never exposed to contributors who could read and game the public file. Node-only — it -// is registered into the Workers-safe loader via setLocalManifestReader at boot (server.ts), so this module's fs -// import never reaches the Cloudflare bundle. +// GITTENSORY_REPO_CONFIG_DIR and configures each repo's review policy there; the focus-manifest loader reads it +// INSTEAD of fetching the public `.gittensory.yml`, so policy (gate, autonomy, labels, model/effort) is configured +// PRIVATELY and never exposed to contributors who could read and game the public file. Node-only — it is registered +// into the Workers-safe loader via setLocalManifestReader at boot (server.ts), so this module's fs import never +// reaches the Cloudflare bundle. +// +// Layout (CodeRabbit-style: per-repo override, then a global fallback). For a repo `JSONbored/gittensory` the +// reader tries, in priority order: +// 1. `jsonbored__gittensory/.gittensory.yml` — owner-qualified folder (robust to repo-name collisions across owners) +// 2. `gittensory/.gittensory.yml` — bare repo-name folder (the clean, human-readable layout) +// 3. `jsonbored__gittensory.yml` — flat owner__repo file (the original #1390 layout; back-compat) +// 4. `.gittensory.yml` — GLOBAL fallback at the dir root: defaults applied to every repo +// that has no per-repo file of its own. +// `.yaml` / `.json` are accepted everywhere `.yml` is. The first existing candidate wins outright (a present +// per-repo file fully REPLACES the global fallback — "fallback" means "used only when no per-repo file exists", +// not a deep merge). The slug is lowercased (GitHub repo full-names are case-insensitive; #1390 already lowercased). import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { RepoFocusManifestFetcher } from "../signals/focus-manifest-loader"; -/** Candidate filenames for a repo's private config, in priority order. The slug is the lowercased GitHub - * `owner__repo` (double underscore because `/` is not filename-safe) — e.g. `JSONbored/metagraphed` → - * `jsonbored__metagraphed.yml`. An invalid repo full name (no single interior slash) yields no candidates. */ +/** The bare config filenames tried inside a per-repo folder and at the dir root (global fallback), in priority order. */ +const CONFIG_BASENAMES = [".gittensory.yml", ".gittensory.yaml", ".gittensory.json"] as const; +/** Global-fallback candidates (relative to GITTENSORY_REPO_CONFIG_DIR): the dir-root `.gittensory.{yml,yaml,json}` + * applied to any repo without its own per-repo file. */ +export const GLOBAL_CONFIG_CANDIDATES: string[] = [...CONFIG_BASENAMES]; + +/** Per-repo private-config candidate paths (relative to GITTENSORY_REPO_CONFIG_DIR), in priority order: + * owner-qualified folder → bare repo-name folder → flat `owner__repo` file (the #1390 back-compat form). The slug + * is the lowercased GitHub `owner__repo` (double underscore because `/` is not filename-safe); the bare folder is + * the lowercased repo name. An invalid repo full name (no single interior slash) yields no candidates. */ export function localConfigCandidates(repoFullName: string): string[] { const slash = repoFullName.indexOf("/"); if (slash <= 0 || slash === repoFullName.length - 1) return []; - const slug = `${repoFullName.slice(0, slash)}__${repoFullName.slice(slash + 1)}`.toLowerCase(); - return [`${slug}.yml`, `${slug}.yaml`, `${slug}.json`]; + const owner = repoFullName.slice(0, slash).toLowerCase(); + const repo = repoFullName.slice(slash + 1).toLowerCase(); + const slug = `${owner}__${repo}`; + return [ + // 1. owner-qualified folder — `{owner}__{repo}/.gittensory.{yml,yaml,json}` + ...CONFIG_BASENAMES.map((base) => join(slug, base)), + // 2. bare repo-name folder — `{repo}/.gittensory.{yml,yaml,json}` + ...CONFIG_BASENAMES.map((base) => join(repo, base)), + // 3. flat owner__repo file (#1390) — `{owner}__{repo}.{yml,yaml,json}` + ...CONFIG_BASENAMES.map((base) => `${slug}${base.slice(".gittensory".length)}`), + ]; } /** Build the container-local manifest reader over GITTENSORY_REPO_CONFIG_DIR, or null when the dir is unset/blank - * (⇒ the loader keeps fetching the public `.gittensory.yml`). Each lookup returns the first existing - * `{dir}/{owner}__{repo}.{yml,yaml,json}` file's text; null when none exist for the repo (⇒ the loader falls - * through to the public file). A read error on one candidate is swallowed so the next candidate is tried. */ + * (⇒ the loader keeps fetching the public `.gittensory.yml`). Each lookup returns the first existing per-repo + * candidate's text; failing that, the global-fallback `.gittensory.{yml,yaml,json}` at the dir root; null when + * neither exists (⇒ the loader falls through to the public file). An invalid repo full name yields no per-repo + * candidates and is NOT served the global fallback (it is never a real webhook repo). A read error on one + * candidate is swallowed so the next candidate is tried. */ export function makeLocalManifestReader(dir: string | undefined): RepoFocusManifestFetcher | null { const base = (dir ?? "").trim(); if (!base) return null; return async (repoFullName: string): Promise => { - for (const candidate of localConfigCandidates(repoFullName)) { + const perRepo = localConfigCandidates(repoFullName); + if (perRepo.length === 0) return null; // invalid repo name → no per-repo file AND no global fallback + for (const candidate of [...perRepo, ...GLOBAL_CONFIG_CANDIDATES]) { try { return await readFile(join(base, candidate), "utf8"); } catch { diff --git a/src/server.ts b/src/server.ts index a11aeb97b1..189d62c085 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,7 +12,11 @@ import { DatabaseSync } from "node:sqlite"; import { serve } from "@hono/node-server"; import worker from "./index"; import { processJob } from "./queue/processors"; -import { createSelfHostAi, resolveAiReviewerPlan } from "./selfhost/ai"; +import { + createOpenAiCompatibleAi, + createSelfHostAi, + resolveAiReviewerPlan, +} from "./selfhost/ai"; import { cookieValue, credentialsToEnv, @@ -291,6 +295,25 @@ async function main(): Promise { provider: process.env.AI_PROVIDER, }), ); + // Dedicated RAG embed provider (keeps the review chain frontier-only): when AI_EMBED_BASE_URL is set, embeddings + // route to a SEPARATE openai-compatible endpoint (e.g. ollama at http://ollama:11434/v1, model bge-m3) instead of + // the review chain — so a Claude/Codex outage never falls reviews back to a weak local model. Unset ⇒ absent ⇒ + // createReviewAdapters falls back to the review `ai` for embeds (byte-identical to before). + const embedAi = process.env.AI_EMBED_BASE_URL + ? createOpenAiCompatibleAi({ + baseUrl: process.env.AI_EMBED_BASE_URL, + apiKey: process.env.AI_EMBED_API_KEY ?? process.env.OPENAI_API_KEY, + embedModel: process.env.AI_EMBED_MODEL, + }) + : undefined; + if (embedAi) + console.log( + JSON.stringify({ + event: "selfhost_embed_provider", + baseUrl: process.env.AI_EMBED_BASE_URL, + model: process.env.AI_EMBED_MODEL ?? "bge-m3", + }), + ); // Dual-review plan (#dual-ai-combiner): resolve which provider(s) review + how to combine, attached to env // below so the review call site uses it. Undefined for a single provider's default review or no AI. const aiReviewPlan = resolveAiReviewerPlan(process.env); @@ -382,6 +405,7 @@ async function main(): Promise { JOBS: backend.queue.binding, WEBHOOKS: backend.queue.binding, // the brokered relay receiver enqueues via WEBHOOKS; both lanes share the in-process queue AI: ai, + ...(embedAi ? { AI_EMBED: embedAi as unknown as Ai } : {}), ...(aiReviewPlan ? { AI_REVIEW_PLAN: aiReviewPlan } : {}), // Qdrant takes priority; falls back to the backend's built-in vectorize (pgvector or sqlite-vec) ...(vectorizeOverride diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts index 8fe2668aa9..03a956d24b 100644 --- a/src/services/ai-review.ts +++ b/src/services/ai-review.ts @@ -22,8 +22,8 @@ import { sumAiEstimatedNeuronsSince, } from "../db/repositories"; import { sanitizePublicComment } from "../queue-intelligence"; -import { defangReviewInput, isSafetyEnabled } from "../review/safety"; -import { isConvergenceRepoAllowed } from "../review/cutover-gate"; +import { defangReviewInput } from "../review/safety"; +import { convergedFeatureActive } from "../review/feature-activation"; import type { ReviewProfile } from "../signals/focus-manifest"; /** @@ -791,13 +791,16 @@ export async function runGittensoryAiReview( // prompt-injection payload never reaches the model verbatim. Flag-OFF (default) passes `input` through // unchanged → the prompt is byte-identical to today. Only the title/body/diff fed to buildUserPrompt are // affected; this NEVER changes the verdict (a redaction is data, not a finding). - // Per-repo cutover gate (GITTENSORY_REVIEW_REPOS): the defang activates for THIS PR's repo only when it - // is allowlisted AND the global safety flag is ON. Empty/unset allowlist → `input` passes through unchanged - // for every repo (the prompt is byte-identical to today) regardless of GITTENSORY_REVIEW_SAFETY. - const promptInput = - isSafetyEnabled(env) && isConvergenceRepoAllowed(env, input.repoFullName) - ? { ...input, ...defangReviewInput(input) } - : input; + // Per-repo feature override (phase 2): the defang activates when the global GITTENSORY_REVIEW_SAFETY kill-switch + // is ON and the repo's container-private `.gittensory.yml` `features.safety` opts in — falling back to the + // GITTENSORY_REVIEW_REPOS allowlist when the manifest says nothing (byte-identical default). + const promptInput = (await convergedFeatureActive( + env, + input.repoFullName, + "safety", + )) + ? { ...input, ...defangReviewInput(input) } + : input; const user = buildUserPrompt(promptInput); // Grounding-discipline SYSTEM suffix (convergence, flag-gated). When the caller supplied grounding, the // reviewers are told to verify claims against the attached CI/files; otherwise this is REVIEW_SYSTEM_PROMPT diff --git a/src/signals/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts index 8bf67e1056..90710224b8 100644 --- a/src/signals/focus-manifest-loader.ts +++ b/src/signals/focus-manifest-loader.ts @@ -1,7 +1,7 @@ import { listSignalSnapshots, persistSignalSnapshot } from "../db/repositories"; import type { JsonValue } from "../types"; import { nowIso } from "../utils/json"; -import { gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, reviewConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource } from "./focus-manifest"; +import { featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, reviewConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource } from "./focus-manifest"; import { GITTENSORY_REPO_FOCUS_MANIFEST_YAML, resolveGittensorySelfRepoFullName } from "../config/gittensory-repo-focus-manifest"; export const REPO_FOCUS_MANIFEST_SIGNAL = "repo-focus-manifest"; @@ -227,6 +227,7 @@ function manifestToJson(manifest: FocusManifest): Record { gate: gateConfigToJson(manifest.gate), settings: settingsOverrideToJson(manifest.settings), review: reviewConfigToJson(manifest.review), + features: featuresConfigToJson(manifest.features), }; } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 8d9b6da90e..376acc2b7b 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -31,12 +31,28 @@ export type FocusManifestGateConfig = { aiReviewByok: boolean | null; aiReviewProvider: "anthropic" | "openai" | null; aiReviewModel: string | null; + aiReviewAllAuthors: boolean | null; mergeReadiness: GateRuleMode | null; manifestPolicy: GateRuleMode | null; selfAuthoredLinkedIssue: GateRuleMode | null; firstTimeContributorGrace: boolean | null; }; +// The converged per-PR review features a self-host operator toggles PER-REPO under `features:` in the private +// `.gittensory.yml`. Each feature ALSO has a GLOBAL env flag (GITTENSORY_REVIEW_*) that stays a master +// kill-switch (the feature never runs when its env flag is off, regardless of this block). See +// review/feature-activation.ts for the resolver (env kill-switch → per-repo override → env-allowlist default). +// NOTE: only the per-PR REVIEW features whose every activation site is migrated are listed here. grounding, +// screenshots, and contentLane stay on the GITTENSORY_REVIEW_REPOS allowlist for now (grounding + contentLane are +// coupled to the merge/close DISPOSITION path; screenshots' capture path needs dedicated coverage) — a follow-up. +export const CONVERGED_FEATURE_KEYS = ["rag", "reputation", "unifiedComment", "safety"] as const; +export type ConvergedFeatureKey = (typeof CONVERGED_FEATURE_KEYS)[number]; + +/** Per-repo activation overrides for the converged review features (`features:` block). `true`/`false` force the + * feature on/off for THIS repo (subject to the env kill-switch); `null` (unset) ⇒ the resolver falls back to the + * `GITTENSORY_REVIEW_REPOS` allowlist default, so an operator who sets nothing keeps today's behavior. */ +export type FocusManifestFeaturesConfig = { present: boolean } & Record; + /** * Generic repository-settings override declared in `.gittensory.yml` under `settings:`. A partial of * {@link RepositorySettings} — every behaviour a maintainer can toggle in the dashboard can be set here @@ -62,6 +78,7 @@ export type FocusManifestSettings = Partial< | "aiReviewByok" | "aiReviewProvider" | "aiReviewModel" + | "aiReviewAllAuthors" | "autoLabelEnabled" | "gittensorLabel" | "createMissingLabel" @@ -165,6 +182,7 @@ export type FocusManifest = { gate: FocusManifestGateConfig; settings: FocusManifestSettings; review: FocusManifestReviewConfig; + features: FocusManifestFeaturesConfig; warnings: string[]; }; @@ -219,12 +237,21 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, + aiReviewAllAuthors: null, mergeReadiness: null, manifestPolicy: null, selfAuthoredLinkedIssue: null, firstTimeContributorGrace: null, }; +const EMPTY_FEATURES_CONFIG: FocusManifestFeaturesConfig = { + present: false, + rag: null, + reputation: null, + unifiedComment: null, + safety: null, +}; + const EMPTY_MANIFEST: FocusManifest = { present: false, source: "none", @@ -239,6 +266,7 @@ const EMPTY_MANIFEST: FocusManifest = { gate: { ...EMPTY_GATE_CONFIG }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null, inlineComments: null, pathInstructions: [], excludePaths: [], preMergeChecks: [] }, + features: { ...EMPTY_FEATURES_CONFIG }, warnings: [], }; @@ -251,7 +279,7 @@ export function isFocusManifestPublicSafe(text: string): boolean { } function emptyManifest(source: FocusManifestSource, warnings: string[] = []): FocusManifest { - return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null, inlineComments: null, pathInstructions: [], excludePaths: [], preMergeChecks: [] } }; + return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null, inlineComments: null, pathInstructions: [], excludePaths: [], preMergeChecks: [] }, features: { ...EMPTY_FEATURES_CONFIG } }; } function normalizeStringList(value: JsonValue | undefined, field: string, warnings: string[]): string[] { @@ -363,6 +391,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu aiReviewByok: normalizeOptionalBoolean(aiReviewRecord?.byok, "gate.aiReview.byok", warnings), aiReviewProvider: normalizeOptionalEnum(aiReviewRecord?.provider, "gate.aiReview.provider", ["anthropic", "openai"] as const, warnings), aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings), + aiReviewAllAuthors: normalizeOptionalBoolean(aiReviewRecord?.allAuthors, "gate.aiReview.allAuthors", warnings), mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings), manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings), selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings), @@ -382,6 +411,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.aiReviewByok !== null || gate.aiReviewProvider !== null || gate.aiReviewModel !== null || + gate.aiReviewAllAuthors !== null || gate.mergeReadiness !== null || gate.manifestPolicy !== null || gate.selfAuthoredLinkedIssue !== null || @@ -413,12 +443,13 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { if (gate.slopAiAdvisory !== null) slop.aiAdvisory = gate.slopAiAdvisory; out.slop = slop; } - if (gate.aiReviewMode !== null || gate.aiReviewByok !== null || gate.aiReviewProvider !== null || gate.aiReviewModel !== null) { + if (gate.aiReviewMode !== null || gate.aiReviewByok !== null || gate.aiReviewProvider !== null || gate.aiReviewModel !== null || gate.aiReviewAllAuthors !== null) { const aiReview: Record = {}; if (gate.aiReviewMode !== null) aiReview.mode = gate.aiReviewMode; if (gate.aiReviewByok !== null) aiReview.byok = gate.aiReviewByok; if (gate.aiReviewProvider !== null) aiReview.provider = gate.aiReviewProvider; if (gate.aiReviewModel !== null) aiReview.model = gate.aiReviewModel; + if (gate.aiReviewAllAuthors !== null) aiReview.allAuthors = gate.aiReviewAllAuthors; out.aiReview = aiReview; } if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness; @@ -428,6 +459,38 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { return out; } +/** + * Parse the optional `features:` mapping — per-repo activation overrides for the converged review features. + * Each recognized key becomes a tri-state (`true`/`false`/`null`); unknown keys and non-boolean values are + * dropped with a warning. `present` is true when at least one key was explicitly set, so an operator can make + * the manifest "present" with only a `features:` block. + */ +function parseFeaturesConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestFeaturesConfig { + const features: FocusManifestFeaturesConfig = { ...EMPTY_FEATURES_CONFIG }; + if (value === undefined || value === null) return features; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('Manifest "features" must be a mapping; ignoring it.'); + return features; + } + const record = value as Record; + for (const key of CONVERGED_FEATURE_KEYS) { + features[key] = normalizeOptionalBoolean(record[key], `features.${key}`, warnings); + } + features.present = CONVERGED_FEATURE_KEYS.some((key) => features[key] !== null); + return features; +} + +/** Serialize a features config back into the parse-compatible `features:` shape so a cached snapshot round-trips + * through {@link parseFeaturesConfig} unchanged. Returns null when nothing is configured. */ +export function featuresConfigToJson(features: FocusManifestFeaturesConfig): JsonValue { + if (!features.present) return null; + const out: Record = {}; + for (const key of CONVERGED_FEATURE_KEYS) { + if (features[key] !== null) out[key] = features[key]; + } + return out; +} + function normalizeOptionalEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null { if (value === undefined || value === null) return null; if (typeof value === "string" && (allowed as readonly string[]).includes(value)) return value as T; @@ -488,7 +551,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) if (blacklistLabel !== null) out.blacklistLabel = blacklistLabel; 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", "autoLabelEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { + for (const key of ["aiReviewByok", "aiReviewAllAuthors", "autoLabelEnabled", "createMissingLabel", "includeMaintainerAuthors", "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", "agentPaused", "agentDryRun"] as const) { const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } @@ -802,6 +865,7 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes if (gate.aiReviewByok !== null) effective.aiReviewByok = gate.aiReviewByok; if (gate.aiReviewProvider !== null) effective.aiReviewProvider = gate.aiReviewProvider; if (gate.aiReviewModel !== null) effective.aiReviewModel = gate.aiReviewModel; + if (gate.aiReviewAllAuthors !== null) effective.aiReviewAllAuthors = gate.aiReviewAllAuthors; if (gate.mergeReadiness !== null) effective.mergeReadinessGateMode = gate.mergeReadiness; if (gate.manifestPolicy !== null) effective.manifestPolicyGateMode = gate.manifestPolicy; if (gate.selfAuthoredLinkedIssue !== null) effective.selfAuthoredLinkedIssueGateMode = gate.selfAuthoredLinkedIssue; @@ -840,6 +904,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): gate: parseGateConfig(record.gate, warnings), settings: parseSettingsOverride(record.settings, warnings), review: parseReviewConfig(record.review, warnings), + features: parseFeaturesConfig(record.features, warnings), warnings, }; if ( @@ -853,7 +918,8 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): manifest.issueDiscoveryPolicy === "neutral" && !manifest.gate.present && Object.keys(manifest.settings).length === 0 && - !manifest.review.present + !manifest.review.present && + !manifest.features.present ) { warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals."); manifest.present = false; diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 32347b5ce0..b4b977c95e 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -203,6 +203,7 @@ export type RepoSettingsPreview = { aiReviewByok: boolean; aiReviewProvider: string | null; aiReviewModel: string | null; + aiReviewAllAuthors: boolean; commandAuthorization: { defaultAllowed: CommandAuthorizationRole[]; commandOverrides: Array<{ command: string; allowedRoles: CommandAuthorizationRole[] }>; @@ -326,6 +327,7 @@ export function buildRepoSettingsPreview(args: { aiReviewByok: settings.aiReviewByok, aiReviewProvider: settings.aiReviewProvider ?? null, aiReviewModel: settings.aiReviewModel ?? null, + aiReviewAllAuthors: settings.aiReviewAllAuthors, commandAuthorization: summarizeCommandAuthorizationPolicy(settings.commandAuthorization), }, commandAuthorizationPreview, diff --git a/src/types.ts b/src/types.ts index 03941f43eb..14c840f9d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -542,6 +542,12 @@ 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. */ + aiReviewAllAuthors: boolean; autoLabelEnabled: boolean; gittensorLabel: string; createMissingLabel: boolean; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 27be323250..c28ca47b0e 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -4062,6 +4062,30 @@ describe("api routes", () => { }); }); + it("POST /v1/internal/jobs/rag-index queues a fan-out (no body) or a single-repo index; 404 when RAG is off", async () => { + const app = createApp(); + const sent: unknown[] = []; + const env = createTestEnv({ + GITTENSORY_REVIEW_RAG: "true", + JOBS: { async send(message: unknown) { sent.push(message); } } as unknown as Queue, + }); + const headers = { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }; + // No body → re-index every configured repo (the operator's "index all my repos" button). + const all = await app.request("/v1/internal/jobs/rag-index", { method: "POST", headers, body: "{}" }, env); + expect(all.status).toBe(202); + await expect(all.json()).resolves.toMatchObject({ ok: true, status: "queued", scope: "all-configured-repos" }); + expect(sent.at(-1)).toEqual({ type: "rag-index-repo", requestedBy: "api" }); + // A repoFullName → index just that repo (adding/refreshing one repo on demand). + const one = await app.request("/v1/internal/jobs/rag-index", { method: "POST", headers, body: JSON.stringify({ repoFullName: " JSONbored/gittensory " }) }, env); + expect(one.status).toBe(202); + await expect(one.json()).resolves.toMatchObject({ scope: "JSONbored/gittensory" }); + expect(sent.at(-1)).toEqual({ type: "rag-index-repo", requestedBy: "api", repoFullName: "JSONbored/gittensory" }); + // RAG globally off → the endpoint does not exist. + const offEnv = createTestEnv({ JOBS: { async send() {} } as unknown as Queue }); + const offHeaders = { authorization: `Bearer ${offEnv.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }; + expect((await app.request("/v1/internal/jobs/rag-index", { method: "POST", headers: offHeaders, body: "{}" }, offEnv)).status).toBe(404); + }); + it("covers live app auth, validation, and internal job queue edge routes", async () => { const app = createApp(); const sent: Array<{ message: unknown; options?: unknown }> = []; diff --git a/test/unit/ai-review-advisory.test.ts b/test/unit/ai-review-advisory.test.ts index c548105569..538576d0dc 100644 --- a/test/unit/ai-review-advisory.test.ts +++ b/test/unit/ai-review-advisory.test.ts @@ -4,6 +4,7 @@ import { BEST_REVIEW_MODELS } from "../../src/services/ai-review"; import { upsertRepositoryAiKey } from "../../src/db/repositories"; import type { Advisory, PullRequestFileRecord, RepositorySettings } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; +import { setLocalManifestReader } from "../../src/signals/focus-manifest-loader"; afterEach(() => { vi.unstubAllGlobals(); @@ -81,6 +82,30 @@ describe("runAiReviewForAdvisory", () => { expect(adv.findings).toEqual([]); }); + it("survives a focus-manifest load failure during feature resolution (fail-safe → allowlist default, review still runs)", async () => { + // loadRepoFocusManifest REJECTS (localManifestReader throws, outside its try/catch) while RAG is flag-enabled, + // so runAiReviewForAdvisory takes the featureManifest-load arm and its `.catch(() => null)` fires; reputation/rag + // then fall back to the (empty) allowlist → no RAG build, the review still runs. + setLocalManifestReader(() => { + throw new Error("manifest read boom"); + }); + try { + const env = aiEnv(async () => ({ response: defectJson() })); + (env as unknown as { GITTENSORY_REVIEW_RAG: string }).GITTENSORY_REVIEW_RAG = "true"; + const result = await runAiReviewForAdvisory(env, { + settings: { aiReviewMode: "block" } as RepositorySettings, + advisory: advisory(), + repoFullName: "acme/widgets", + pr, + author: "alice", + confirmedContributor: true, + }); + expect(result).toBeDefined(); + } finally { + setLocalManifestReader(null); + } + }); + it("no-ops for a non-confirmed contributor under the gittensor pack and when there is no head SHA", async () => { const env = aiEnv(async () => ({ response: defectJson() })); const base = { settings: { aiReviewMode: "block", gatePack: "gittensor" } as RepositorySettings, repoFullName: "acme/widgets", pr, author: "alice" }; @@ -104,6 +129,24 @@ 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. + const adv = advisory(); + const result = await runAiReviewForAdvisory(aiEnv(async () => ({ response: notesOnlyJson() })), { + settings: { aiReviewMode: "advisory", gatePack: "gittensor", aiReviewAllAuthors: true } as RepositorySettings, + advisory: adv, + repoFullName: "acme/widgets", + pr, + author: "alice", + confirmedContributor: false, + }); + expect(result?.notes).toContain("Add a test."); + expect(adv.findings).toEqual([]); // advisory mode: notes only, no blocker + }); + it("appends an ai_consensus_defect finding in block mode when the models agree", async () => { const adv = advisory(); const result = await runAiReviewForAdvisory(aiEnv(async () => ({ response: defectJson() })), { diff --git a/test/unit/cutover-gate.test.ts b/test/unit/cutover-gate.test.ts index e56197860f..54ff1cd731 100644 --- a/test/unit/cutover-gate.test.ts +++ b/test/unit/cutover-gate.test.ts @@ -1,5 +1,19 @@ import { describe, expect, it } from "vitest"; -import { isConvergenceRepoAllowed } from "../../src/review/cutover-gate"; +import { isConvergenceRepoAllowed, listConvergenceRepos } from "../../src/review/cutover-gate"; + +describe("listConvergenceRepos — the configured repo set (for proactive RAG indexing)", () => { + it("parses, trims, and drops empty entries", () => { + expect(listConvergenceRepos({ GITTENSORY_REVIEW_REPOS: " JSONbored/gittensory , JSONbored/metagraphed ,, " })).toEqual(["JSONbored/gittensory", "JSONbored/metagraphed"]); + }); + it("returns [] when unset or empty", () => { + expect(listConvergenceRepos({})).toEqual([]); + expect(listConvergenceRepos({ GITTENSORY_REVIEW_REPOS: "" })).toEqual([]); + expect(listConvergenceRepos({ GITTENSORY_REVIEW_REPOS: " , ,, " })).toEqual([]); + }); + it("dedupes case-insensitively, preserving the first occurrence's original case", () => { + expect(listConvergenceRepos({ GITTENSORY_REVIEW_REPOS: "JSONbored/Gittensory, jsonbored/gittensory, JSONbored/metagraphed" })).toEqual(["JSONbored/Gittensory", "JSONbored/metagraphed"]); + }); +}); describe("isConvergenceRepoAllowed — per-repo review allowlist", () => { it("empty / unset / whitespace-only allowlist → false for every repo (the dormant default)", () => { diff --git a/test/unit/feature-activation.test.ts b/test/unit/feature-activation.test.ts new file mode 100644 index 0000000000..2f88bffcad --- /dev/null +++ b/test/unit/feature-activation.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { convergedFeatureActive, resolveConvergedFeature } from "../../src/review/feature-activation"; +import { CONVERGED_FEATURE_KEYS, type ConvergedFeatureKey, type FocusManifest } from "../../src/signals/focus-manifest"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "JSONbored/gittensory"; + +// The global env flag (master kill-switch) name for each feature, so a test can flip exactly one feature on. +const FLAG: Record = { + rag: "GITTENSORY_REVIEW_RAG", + reputation: "GITTENSORY_REVIEW_REPUTATION", + unifiedComment: "GITTENSORY_REVIEW_UNIFIED_COMMENT", + safety: "GITTENSORY_REVIEW_SAFETY", +}; + +function env(overrides: Record): Env { + return overrides as unknown as Env; +} +function manifestWith(features: Partial>): Pick { + const base = { present: false, rag: null, reputation: null, unifiedComment: null, safety: null } as FocusManifest["features"]; + return { features: { ...base, ...features, present: Object.keys(features).length > 0 } }; +} + +describe("resolveConvergedFeature — env kill-switch → per-repo override → allowlist default", () => { + it("returns false when the global env flag is off, regardless of a per-repo override or the allowlist", () => { + // flag off, override true, repo allowlisted → still off (kill-switch wins). + expect(resolveConvergedFeature(env({ GITTENSORY_REVIEW_REPOS: REPO }), manifestWith({ rag: true }), "rag", REPO)).toBe(false); + }); + + it("honors an explicit per-repo override (true) even when the repo is NOT in the allowlist", () => { + expect(resolveConvergedFeature(env({ GITTENSORY_REVIEW_RAG: "true" }), manifestWith({ rag: true }), "rag", REPO)).toBe(true); + }); + + it("honors an explicit per-repo override (false) even when the repo IS in the allowlist", () => { + const e = env({ GITTENSORY_REVIEW_RAG: "true", GITTENSORY_REVIEW_REPOS: REPO }); + expect(resolveConvergedFeature(e, manifestWith({ rag: false }), "rag", REPO)).toBe(false); + }); + + it("falls back to the GITTENSORY_REVIEW_REPOS allowlist when the manifest sets nothing (back-compat default)", () => { + const on = env({ GITTENSORY_REVIEW_RAG: "true", GITTENSORY_REVIEW_REPOS: REPO }); + expect(resolveConvergedFeature(on, manifestWith({}), "rag", REPO)).toBe(true); // allowlisted → default on + expect(resolveConvergedFeature(on, null, "rag", REPO)).toBe(true); // null manifest tolerated + const off = env({ GITTENSORY_REVIEW_RAG: "true", GITTENSORY_REVIEW_REPOS: "other/repo" }); + expect(resolveConvergedFeature(off, manifestWith({}), "rag", REPO)).toBe(false); // not allowlisted → default off + }); + + it("maps every converged feature key to its own global flag (one flag on never activates another feature)", () => { + for (const key of CONVERGED_FEATURE_KEYS) { + const e = env({ [FLAG[key]]: "true", GITTENSORY_REVIEW_REPOS: REPO }); + expect(resolveConvergedFeature(e, manifestWith({}), key, REPO)).toBe(true); // its own flag activates it + // A different feature stays off (its flag is unset), proving no cross-wiring. + const other = CONVERGED_FEATURE_KEYS.find((k) => k !== key)!; + expect(resolveConvergedFeature(e, manifestWith({}), other, REPO)).toBe(false); + } + }); +}); + +describe("convergedFeatureActive — async (loads the cached manifest)", () => { + it("short-circuits to false WITHOUT loading the manifest when the env flag is off", async () => { + // DB-less env: if it tried to load the manifest it would throw; returning false proves the short-circuit. + expect(await convergedFeatureActive({} as Env, REPO, "rag")).toBe(false); + }); + + it("loads the manifest and applies a per-repo override (override beats the allowlist)", async () => { + const e = createTestEnv({ GITTENSORY_REVIEW_RAG: "true", GITTENSORY_REVIEW_REPOS: REPO }); + // Allowlisted (default would be ON) but the per-repo manifest forces it OFF. + await upsertRepoFocusManifest(e, REPO, { features: { rag: false } }); + expect(await convergedFeatureActive(e, REPO, "rag")).toBe(false); + }); + + it("falls back to the allowlist default when no manifest is published", async () => { + const e = createTestEnv({ GITTENSORY_REVIEW_RAG: "true", GITTENSORY_REVIEW_REPOS: REPO }); + expect(await convergedFeatureActive(e, REPO, "rag")).toBe(true); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index b64ad7ce8d..b64fae15ea 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -3,6 +3,7 @@ import { buildFocusManifestGuidance, compileFocusManifestPolicy, deriveContributionLanes, + featuresConfigToJson, gateConfigToJson, isFocusManifestPublicSafe, matchesManifestPath, @@ -475,9 +476,10 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, firstTimeContributorGrace: null }, + gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, firstTimeContributorGrace: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null, inlineComments: null, pathInstructions: [], excludePaths: [], preMergeChecks: [] }, + features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null }, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -763,7 +765,7 @@ describe("parseFocusManifest gate config", () => { it("parses a full gate section including the readiness block", () => { const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "block", minScore: 70 } } }); expect(m.present).toBe(true); - expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, firstTimeContributorGrace: null }); + expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, aiReviewAllAuthors: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, firstTimeContributorGrace: null }); }); it("parses gate.mergeReadiness + gate.firstTimeContributorGrace, round-trips them, and warns on bad values (#822)", () => { @@ -778,6 +780,18 @@ describe("parseFocusManifest gate config", () => { expect(bad.gate.present).toBe(false); }); + it("parses gate.selfAuthoredLinkedIssue + settings.selfAuthoredLinkedIssueGateMode, round-trips + resolves them (the gate alias wins)", () => { + const m = parseFocusManifest({ gate: { selfAuthoredLinkedIssue: "block" }, settings: { selfAuthoredLinkedIssueGateMode: "advisory" } }); + expect(m.gate.present).toBe(true); + expect(m.gate.selfAuthoredLinkedIssue).toBe("block"); + expect(m.settings.selfAuthoredLinkedIssueGateMode).toBe("advisory"); + expect(gateConfigToJson(m.gate)).toMatchObject({ selfAuthoredLinkedIssue: "block" }); + const eff = resolveEffectiveSettings({ selfAuthoredLinkedIssueGateMode: "off" } as RepositorySettings, m); + expect(eff.selfAuthoredLinkedIssueGateMode).toBe("block"); + const bad = parseFocusManifest({ gate: { selfAuthoredLinkedIssue: "sometimes" } }); + expect(bad.gate.selfAuthoredLinkedIssue).toBeNull(); + }); + it("parses gate.manifestPolicy, round-trips it through gateConfigToJson, and warns + nulls on a bad value (#555)", () => { const m = parseFocusManifest({ gate: { manifestPolicy: "block" } }); expect(m.gate.present).toBe(true); @@ -901,6 +915,47 @@ describe("parseFocusManifest gate config", () => { expect(parseFocusManifest({ gate: { aiReview: { mode: "loud" } } }).warnings.some((w) => /gate\.aiReview\.mode/.test(w))).toBe(true); }); + it("parses gate.aiReview.allAuthors, makes the gate present, round-trips it, and resolves it into effective settings", () => { + // allAuthors alone makes the gate present (so an operator can set ONLY this), serializes back under + // gate.aiReview.allAuthors, and the gate alias projects it onto effective settings. + const m = parseFocusManifest({ gate: { aiReview: { allAuthors: true } } }); + expect(m.gate.present).toBe(true); + expect(m.gate.aiReviewAllAuthors).toBe(true); + expect((gateConfigToJson(m.gate) as { aiReview: { allAuthors: boolean } }).aiReview.allAuthors).toBe(true); + expect(parseFocusManifest({ gate: gateConfigToJson(m.gate) }).gate).toEqual(m.gate); // round-trips + expect(parseFocusManifest({ gate: { aiReview: { allAuthors: "yes" } } }).warnings.some((w) => /gate\.aiReview\.allAuthors/.test(w))).toBe(true); + const eff = resolveEffectiveSettings({ aiReviewAllAuthors: false } as unknown as RepositorySettings, m); + expect(eff.aiReviewAllAuthors).toBe(true); + // Absent ⇒ null ⇒ the gate alias leaves the DB value untouched. + const noFlag = parseFocusManifest({ gate: { aiReview: { mode: "advisory" } } }); + expect(noFlag.gate.aiReviewAllAuthors).toBeNull(); + expect(resolveEffectiveSettings({ aiReviewAllAuthors: true } as unknown as RepositorySettings, noFlag).aiReviewAllAuthors).toBe(true); + }); + + it("parses the features: block (per-repo converged-feature toggles), round-trips it, and makes the manifest present", () => { + const m = parseFocusManifest({ features: { rag: true, reputation: false, unifiedComment: true } }); + expect(m.present).toBe(true); + expect(m.features.present).toBe(true); + expect(m.features.rag).toBe(true); + expect(m.features.reputation).toBe(false); + expect(m.features.unifiedComment).toBe(true); + expect(m.features.safety).toBeNull(); // unset stays null (⇒ allowlist default at resolve time) + // Round-trips through featuresConfigToJson → parseFocusManifest unchanged. + expect(parseFocusManifest({ features: featuresConfigToJson(m.features) }).features).toEqual(m.features); + // A non-boolean value warns and is dropped (stays null); a non-mapping warns. + expect(parseFocusManifest({ features: { rag: "yes" } }).warnings.some((w) => /features\.rag/.test(w))).toBe(true); + expect(parseFocusManifest({ features: ["nope"] }).warnings.some((w) => /"features" must be a mapping/.test(w))).toBe(true); + // An empty features block leaves the manifest absent (no recognized fields). + expect(parseFocusManifest({ features: {} }).features.present).toBe(false); + expect(featuresConfigToJson(parseFocusManifest({ features: {} }).features)).toBeNull(); + }); + + it("parses aiReviewAllAuthors from the settings: block (generic override)", () => { + const parsed = parseFocusManifest({ settings: { aiReviewAllAuthors: true } }); + expect(parsed.settings.aiReviewAllAuthors).toBe(true); + expect(resolveEffectiveSettings({ aiReviewAllAuthors: false } as unknown as RepositorySettings, parsed).aiReviewAllAuthors).toBe(true); + }); + it("parses gate.aiReview provider + model (config-as-code) and rejects an unknown provider", () => { const m = parseFocusManifest({ gate: { aiReview: { mode: "advisory", byok: true, provider: "anthropic", model: "claude-3-5-sonnet-latest" } } }); expect(m.gate.aiReviewProvider).toBe("anthropic"); diff --git a/test/unit/maintainer-activation.test.ts b/test/unit/maintainer-activation.test.ts index 7b9c2c40e3..4667e61089 100644 --- a/test/unit/maintainer-activation.test.ts +++ b/test/unit/maintainer-activation.test.ts @@ -49,6 +49,7 @@ function settings(overrides: Partial = {}): RepositorySettin privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, + aiReviewAllAuthors: false, ...overrides, }; } diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts index d22d700649..f52390b57d 100644 --- a/test/unit/policy-sanitizer.test.ts +++ b/test/unit/policy-sanitizer.test.ts @@ -83,6 +83,7 @@ function settingsFor(repoFullName: string, overrides: Partial { - it("builds lowercased {owner}__{repo} candidates in .yml/.yaml/.json order", () => { - expect(localConfigCandidates("JSONbored/metagraphed")).toEqual(["jsonbored__metagraphed.yml", "jsonbored__metagraphed.yaml", "jsonbored__metagraphed.json"]); +describe("localConfigCandidates (container-private config paths)", () => { + it("builds owner-folder → repo-folder → flat candidates (lowercased), each in .yml/.yaml/.json order", () => { + expect(localConfigCandidates("JSONbored/metagraphed")).toEqual([ + // 1. owner-qualified folder + join("jsonbored__metagraphed", ".gittensory.yml"), + join("jsonbored__metagraphed", ".gittensory.yaml"), + join("jsonbored__metagraphed", ".gittensory.json"), + // 2. bare repo-name folder + join("metagraphed", ".gittensory.yml"), + join("metagraphed", ".gittensory.yaml"), + join("metagraphed", ".gittensory.json"), + // 3. flat owner__repo file (#1390 back-compat) + "jsonbored__metagraphed.yml", + "jsonbored__metagraphed.yaml", + "jsonbored__metagraphed.json", + ]); }); it("returns no candidates for an invalid repo full name", () => { expect(localConfigCandidates("no-slash")).toEqual([]); // slash < 0 → slash <= 0 expect(localConfigCandidates("/leading")).toEqual([]); // slash at 0 → slash <= 0 expect(localConfigCandidates("trailing/")).toEqual([]); // slash at len-1 }); + it("exposes the dir-root global-fallback candidates", () => { + expect(GLOBAL_CONFIG_CANDIDATES).toEqual([".gittensory.yml", ".gittensory.yaml", ".gittensory.json"]); + }); }); describe("makeLocalManifestReader (GITTENSORY_REPO_CONFIG_DIR)", () => { @@ -22,30 +38,56 @@ describe("makeLocalManifestReader (GITTENSORY_REPO_CONFIG_DIR)", () => { expect(makeLocalManifestReader(" ")).toBeNull(); // blank after trim }); - it("reads the first existing {owner}__{repo} file and returns its text", async () => { + it("reads the owner-qualified folder file first (highest-priority per-repo candidate)", async () => { const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); - writeFileSync(join(dir, "jsonbored__metagraphed.yml"), "gate:\n enabled: false\n"); + mkdirSync(join(dir, "jsonbored__metagraphed")); + writeFileSync(join(dir, "jsonbored__metagraphed", ".gittensory.yml"), "gate:\n enabled: false\n"); const reader = makeLocalManifestReader(dir); expect(reader).not.toBeNull(); expect(await reader!("JSONbored/metagraphed")).toBe("gate:\n enabled: false\n"); }); - it("falls through .yml → .yaml → .json when earlier candidates are absent (read error → next)", async () => { + it("falls back to the bare repo-name folder when no owner-qualified folder exists", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); + mkdirSync(join(dir, "metagraphed")); + writeFileSync(join(dir, "metagraphed", ".gittensory.yaml"), "gate:\n enabled: true\n"); + const reader = makeLocalManifestReader(dir); + expect(await reader!("JSONbored/metagraphed")).toBe("gate:\n enabled: true\n"); + }); + + it("still reads the flat {owner}__{repo}.json file (#1390 back-compat)", async () => { const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); writeFileSync(join(dir, "owner__repo.json"), '{"gate":{"enabled":true}}'); const reader = makeLocalManifestReader(dir); expect(await reader!("owner/repo")).toBe('{"gate":{"enabled":true}}'); }); - it("returns null when no private config file exists for the repo (⇒ loader uses the public file)", async () => { + it("falls back to the dir-root global .gittensory.yml for a repo with no per-repo file", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); + writeFileSync(join(dir, ".gittensory.yml"), "gate:\n enabled: false\n"); + const reader = makeLocalManifestReader(dir); + expect(await reader!("owner/unconfigured")).toBe("gate:\n enabled: false\n"); + }); + + it("prefers a per-repo file over the global fallback when both exist", async () => { + const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); + writeFileSync(join(dir, ".gittensory.yml"), "gate:\n enabled: false\n"); // global + mkdirSync(join(dir, "repo")); + writeFileSync(join(dir, "repo", ".gittensory.yml"), "gate:\n enabled: true\n"); // per-repo wins + const reader = makeLocalManifestReader(dir); + expect(await reader!("owner/repo")).toBe("gate:\n enabled: true\n"); + }); + + it("returns null when neither a per-repo file nor a global fallback exists (⇒ loader uses the public file)", async () => { const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); const reader = makeLocalManifestReader(dir); expect(await reader!("owner/unconfigured")).toBeNull(); }); - it("returns null for an invalid repo full name (no candidates to try)", async () => { + it("does NOT serve the global fallback to an invalid repo full name (no per-repo candidates)", async () => { const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-")); + writeFileSync(join(dir, ".gittensory.yml"), "gate:\n enabled: false\n"); // global present const reader = makeLocalManifestReader(dir); - expect(await reader!("no-slash")).toBeNull(); + expect(await reader!("no-slash")).toBeNull(); // perRepo.length === 0 early return }); }); diff --git a/test/unit/rag-index.test.ts b/test/unit/rag-index.test.ts index 74e4a3950c..153eef8598 100644 --- a/test/unit/rag-index.test.ts +++ b/test/unit/rag-index.test.ts @@ -3,6 +3,7 @@ import { indexRepo, reindexChangedPaths } from "../../src/review/rag-index"; import { MAX_CHUNKS_PER_REPO, MAX_FILE_BYTES, RAG_DIMENSIONS, ragNamespace } from "../../src/review/rag"; import { processJob, splitRepoForRag } from "../../src/queue/processors"; import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; import { createTestEnv, TestD1Database } from "../helpers/d1"; // A valid bge-m3-width (1024-d) embedding vector — embedTexts rejects any other width. @@ -491,6 +492,30 @@ describe("rag-index-repo job dispatch (processors.ts wiring)", () => { expect(JSON.parse(fanout?.metadata_json ?? "{}")).toMatchObject({ repoCount: 1, requestedBy: "schedule" }); }); + it("cron fan-out ALSO indexes CONFIGURED (GITTENSORY_REVIEW_REPOS) repos never registered via webhook (brokered self-host fix)", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITTENSORY_REVIEW_RAG: "true", + GITTENSORY_REVIEW_REPOS: "JSONbored/metagraphed, JSONbored/gittensory", // configured, NOT registered (is_registered=0) + JOBS: { async send(message: import("../../src/types").JobMessage) { sent.push(message); } } as unknown as Queue, + }); + // No registerRepo() — these are is_registered=0 (the brokered model); the old registered-only fan-out indexed NOTHING. + await processJob(env, { type: "rag-index-repo", requestedBy: "schedule" }); + expect(sent.map((m) => (m as { repoFullName?: string }).repoFullName).sort()).toEqual(["JSONbored/gittensory", "JSONbored/metagraphed"]); + }); + + it("dedupes a repo that is BOTH registered and configured (no double-index)", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITTENSORY_REVIEW_RAG: "true", + GITTENSORY_REVIEW_REPOS: "JSONbored/gittensory", + JOBS: { async send(message: import("../../src/types").JobMessage) { sent.push(message); } } as unknown as Queue, + }); + await registerRepo(env, "JSONbored/gittensory"); // registered AND configured → must appear exactly once + await processJob(env, { type: "rag-index-repo", requestedBy: "schedule" }); + expect(sent.filter((m) => (m as { repoFullName?: string }).repoFullName === "JSONbored/gittensory").length).toBe(1); + }); + it("FLAG-OFF cron fan-out is a no-op (no per-repo jobs enqueued, no fan-out audit)", async () => { const sent: import("../../src/types").JobMessage[] = []; const env = createTestEnv({ @@ -512,21 +537,39 @@ describe("rag-index-repo job dispatch (processors.ts wiring)", () => { expect(await countChunks(env, QUEUE_PROJECT, "gittensory")).toBe(1); }); - it("per-repo dispatch SKIPS a non-allowlisted repo (no indexing)", async () => { + it("per-repo dispatch SKIPS a repo where RAG is not active (no indexing)", async () => { const env = createTestEnv({ GITTENSORY_REVIEW_RAG: "true", - GITTENSORY_REVIEW_REPOS: "", // empty allowlist → nothing converged + GITTENSORY_REVIEW_REPOS: "", // empty allowlist → not active (no per-repo features.rag override either) VECTORIZE: vectorizeStub() as unknown as Vectorize, AI: aiStub() as unknown as Ai, }); await registerRepo(env, "JSONbored/gittensory"); - const fetchSpy = vi.fn(); + // The manifest IS consulted now (that's how a per-repo `features.rag: true` override would activate an + // un-allowlisted repo); it returns no manifest here, so the repo stays inactive and is never indexed. + const fetchSpy = vi.fn(async (url: RequestInfo | URL) => new Response("", { status: 404, headers: { "x-url": String(url) } })); vi.stubGlobal("fetch", fetchSpy); await processJob(env, { type: "rag-index-repo", requestedBy: "schedule", repoFullName: "JSONbored/gittensory" }); - expect(fetchSpy).not.toHaveBeenCalled(); + // No indexing work: the GitHub git/trees endpoint (the index walk) was never hit. + expect(fetchSpy.mock.calls.some((call) => String(call[0]).includes("/git/trees/"))).toBe(false); expect(await countChunks(env, QUEUE_PROJECT, "gittensory")).toBe(0); }); + it("per-repo dispatch INDEXES an un-allowlisted repo when features.rag is overridden on via the private config", async () => { + const env = createTestEnv({ + GITTENSORY_REVIEW_RAG: "true", + GITTENSORY_REVIEW_REPOS: "", // not allowlisted — only the per-repo override activates it + VECTORIZE: vectorizeStub() as unknown as Vectorize, + AI: aiStub() as unknown as Ai, + }); + await registerRepo(env, "JSONbored/gittensory"); + // Private-config override: features.rag = true. upsert persists it as an api_record the loader reads. + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { features: { rag: true } }); + stubGithub({ tree: [{ path: "src/a.ts", size: 30 }], files: { "src/a.ts": "export const a = 1;\n" } }); + await processJob(env, { type: "rag-index-repo", requestedBy: "schedule", repoFullName: "JSONbored/gittensory" }); + expect(await countChunks(env, QUEUE_PROJECT, "gittensory")).toBe(1); // indexed despite the empty allowlist + }); + it("per-repo INCREMENTAL dispatch (with paths) runs reindexChangedPaths", async () => { const { env } = indexEnv({ rag: "true" }); await registerRepo(env, "JSONbored/gittensory"); diff --git a/test/unit/registration-readiness.test.ts b/test/unit/registration-readiness.test.ts index 880039f817..43ac7b66b4 100644 --- a/test/unit/registration-readiness.test.ts +++ b/test/unit/registration-readiness.test.ts @@ -63,6 +63,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, + aiReviewAllAuthors: false, ...overrides, }; } diff --git a/test/unit/repository-settings-enforcement.test.ts b/test/unit/repository-settings-enforcement.test.ts index 804a3f5ba2..1db2943a88 100644 --- a/test/unit/repository-settings-enforcement.test.ts +++ b/test/unit/repository-settings-enforcement.test.ts @@ -37,6 +37,7 @@ function settings(over: Partial = {}): RepositorySettings { privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, + aiReviewAllAuthors: false, aiReviewProvider: null, aiReviewModel: null, ...over, diff --git a/test/unit/review-adapters.test.ts b/test/unit/review-adapters.test.ts index 86ad017987..9e558e734a 100644 --- a/test/unit/review-adapters.test.ts +++ b/test/unit/review-adapters.test.ts @@ -62,6 +62,26 @@ describe("createReviewAdapters: bundle assembly + graceful degradation", () => { expect(infra.inference).toBeDefined(); }); + it("prefers the dedicated AI_EMBED provider for inference, keeping the review chain frontier-only", async () => { + const { DB } = dbStub(); + const reviewAi = { run: vi.fn(async () => ({ response: "review text" })) }; // would NOT return embed data + const embedAi = { run: vi.fn(async () => ({ data: [[0.1, 0.2]] })) }; + const infra = createReviewAdapters({ DB, VECTORIZE: vectorizeStub(), AI: reviewAi, AI_EMBED: embedAi } as unknown as Env); + // The embed call goes to AI_EMBED (ollama), never the review chain. + await infra.inference!.run("bge-m3", { text: ["hi"] }); + expect(embedAi.run).toHaveBeenCalledTimes(1); + expect(reviewAi.run).not.toHaveBeenCalled(); + }); + + it("falls back to env.AI for inference when no dedicated AI_EMBED is configured (byte-identical to before)", async () => { + const { DB } = dbStub(); + const ai = aiStub(); + const infra = createReviewAdapters({ DB, AI: ai } as unknown as Env); + expect(infra.inference).toBeDefined(); + await infra.inference!.run("bge-m3", { text: ["hi"] }); + expect(ai.run).toHaveBeenCalledTimes(1); + }); + it("degrades to no-RAG/no-context when VECTORIZE and AI are absent (storage always present, never throws)", () => { const { DB } = dbStub(); const infra = createReviewAdapters({ DB } as unknown as Env); diff --git a/test/unit/routes-ai-byok.test.ts b/test/unit/routes-ai-byok.test.ts index 80149883fb..894872208a 100644 --- a/test/unit/routes-ai-byok.test.ts +++ b/test/unit/routes-ai-byok.test.ts @@ -35,13 +35,14 @@ describe("maintainer AI-review config route", () => { await upsertRepositorySettings(env, { repoFullName: REPO, gateCheckMode: "enabled", gittensorLabel: "custom-label", blacklistLabel: "abuse" }); const res = await app.request( `/v1/repos/${REPO}/ai-review`, - { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "block", byok: true, provider: "anthropic", model: "claude-3-5-sonnet-latest" }) }, + { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "block", byok: true, provider: "anthropic", model: "claude-3-5-sonnet-latest", allAuthors: true }) }, env, ); expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ aiReviewMode: "block", aiReviewByok: true, aiReviewProvider: "anthropic", aiReviewModel: "claude-3-5-sonnet-latest" }); + expect(await res.json()).toMatchObject({ aiReviewMode: "block", aiReviewByok: true, aiReviewProvider: "anthropic", aiReviewModel: "claude-3-5-sonnet-latest", aiReviewAllAuthors: true }); const settings = await getRepositorySettings(env, REPO); expect(settings.aiReviewMode).toBe("block"); + expect(settings.aiReviewAllAuthors).toBe(true); // persisted + read back (DB column round-trip) expect(settings.gateCheckMode).toBe("enabled"); // preserved expect(settings.gittensorLabel).toBe("custom-label"); // preserved expect(settings.blacklistLabel).toBe("abuse"); // #1425 round-trips through the DB diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index ae11357cf7..8d69ab6d81 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -76,6 +76,7 @@ function settingsFor(repoFullName: string, overrides: Partial { const WORKERS_DEFAULT = "@cf/meta/llama-3.1-8b-instruct-fp8-fast"; @@ -30,6 +30,26 @@ describe("resolveEffort (#selfhost-effort — Claude Code intelligence dial, def }); }); +describe("resolveCliTimeoutMs (#selfhost — subprocess timeout scales with effort, AI_TIMEOUT_MS overrides)", () => { + it("scales the default timeout with the AI_EFFORT dial (max needs far more than the old fixed 120s)", () => { + expect(resolveCliTimeoutMs({ AI_EFFORT: "low" })).toBe(120_000); + expect(resolveCliTimeoutMs({ AI_EFFORT: "medium" })).toBe(120_000); + expect(resolveCliTimeoutMs({ AI_EFFORT: "high" })).toBe(240_000); + expect(resolveCliTimeoutMs({ AI_EFFORT: "xhigh" })).toBe(360_000); + expect(resolveCliTimeoutMs({ AI_EFFORT: "max" })).toBe(600_000); + expect(resolveCliTimeoutMs({})).toBe(240_000); // unset effort → resolveEffort defaults to high + }); + it("honors an explicit AI_TIMEOUT_MS, clamped to a sane 30s–30min range", () => { + expect(resolveCliTimeoutMs({ AI_TIMEOUT_MS: "300000", AI_EFFORT: "low" })).toBe(300_000); // in-range value wins over the effort scale + expect(resolveCliTimeoutMs({ AI_TIMEOUT_MS: "9999999" })).toBe(1_800_000); // clamped down to the 30min ceiling + expect(resolveCliTimeoutMs({ AI_TIMEOUT_MS: "1000" })).toBe(30_000); // clamped up to the 30s floor + }); + it("falls back to the effort scale on a non-positive or non-numeric AI_TIMEOUT_MS", () => { + expect(resolveCliTimeoutMs({ AI_TIMEOUT_MS: "0", AI_EFFORT: "max" })).toBe(600_000); // 0 is not > 0 → effort path + expect(resolveCliTimeoutMs({ AI_TIMEOUT_MS: "abc", AI_EFFORT: "high" })).toBe(240_000); // NaN → effort path + }); +}); + afterEach(() => vi.unstubAllGlobals()); type SpawnResult = { stdout: string; code: number | null }; @@ -309,30 +329,51 @@ describe("subscription CLI helpers + fail-safe", () => { expect(capturedEnv.CLAUDE_CODE_OAUTH_TOKEN).toBe("t"); }); - it("Claude Code pins the default model (claude-sonnet-4-6) + --effort high; AI_MODEL/AI_EFFORT override", async () => { + it("Claude Code pins the default model (claude-sonnet-4-6) + --effort high; AI_MODEL/AI_EFFORT override; timeout scales with effort", async () => { let seen: string[] = []; - const cap: StubSpawn = async (_c, a) => { + let timeout = 0; + const cap: StubSpawn = async (_c, a, o) => { seen = a; + timeout = o.timeoutMs; return { stdout: JSON.stringify({ type: "result", result: "ok" }), code: 0 }; }; // empty model id (the dual-router default) + no AI_MODEL → pinned claude-sonnet-4-6; no AI_EFFORT → high await createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t" }, cap).run("", { prompt: "x" }); expect(seen[seen.indexOf("--model") + 1]).toBe("claude-sonnet-4-6"); expect(seen[seen.indexOf("--effort") + 1]).toBe("high"); - // operator overrides flow through to the argv - await createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t", AI_MODEL: "claude-opus-4-8", AI_EFFORT: "low" }, cap).run("", { prompt: "x" }); + expect(timeout).toBe(240_000); // high → 240s (not the old fixed 120s) + // operator overrides flow through to the argv + the timeout scale + await createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t", AI_MODEL: "claude-opus-4-8", AI_EFFORT: "max" }, cap).run("", { prompt: "x" }); expect(seen[seen.indexOf("--model") + 1]).toBe("claude-opus-4-8"); - expect(seen[seen.indexOf("--effort") + 1]).toBe("low"); + expect(seen[seen.indexOf("--effort") + 1]).toBe("max"); + expect(timeout).toBe(600_000); // max → 600s, so a large max-effort review isn't SIGKILLed at 120s + }); + + it("chat-only CLIs reject embeds so the chain routes embeddings to an embed-capable provider (Claude review + ollama embed)", async () => { + const reviewOk: StubSpawn = async () => ({ stdout: JSON.stringify({ type: "result", result: "the review" }), code: 0 }); + // A stand-in embed-capable provider (e.g. ollama): returns `data` for an embed request, `response` for chat. + const embedder = { name: "ollama", ai: { run: async (_m: string, o: { text?: string[] }) => (o.text ? { data: o.text.map(() => [0.1, 0.2]) } : { response: "ollama chat" }) } }; + const claudeChain = createChainAi([{ name: "claude-code", ai: createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t" }, reviewOk) }, embedder]); + // A CHAT/review request is served by claude-code (the frontier reviewer), never the embedder. + expect((await claudeChain.run("m", { prompt: "review this" })).response).toBe("the review"); + // An EMBED request makes claude-code throw → the chain falls through to ollama, which returns vectors. + expect((await claudeChain.run("bge-m3", { text: ["a", "b"] })).data?.length).toBe(2); + // Same for codex as the frontier reviewer. + const codexOk: StubSpawn = async () => ({ stdout: JSON.stringify({ type: "result", result: "codex review" }), code: 0 }); + const codexChain = createChainAi([{ name: "codex", ai: createCodexAi({}, codexOk) }, embedder]); + expect((await codexChain.run("bge-m3", { text: ["a"] })).data?.length).toBe(1); }); it("Codex: 0.142+ exec flags (no --ask-for-approval, has --skip-git-repo-check); --model only when configured", async () => { let seen: string[] = []; - const ok: StubSpawn = async (_cmd, args) => { seen = args; return { stdout: JSON.stringify({ type: "result", result: "codex review" }), code: 0 }; }; + let timeout = 0; + const ok: StubSpawn = async (_cmd, args, o) => { seen = args; timeout = o.timeoutMs; return { stdout: JSON.stringify({ type: "result", result: "codex review" }), code: 0 }; }; // no configured model + the dual-router's empty model id → OMIT --model (codex picks the account default; // forcing e.g. gpt-5 fails on a ChatGPT-account login). And the removed --ask-for-approval must never appear. - expect((await createCodexAi({}, ok).run("", { prompt: "x" })).response).toBe("codex review"); + expect((await createCodexAi({ AI_TIMEOUT_MS: "300000" }, ok).run("", { prompt: "x" })).response).toBe("codex review"); expect(seen).toEqual(["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only", "--", "x"]); expect(seen).not.toContain("--ask-for-approval"); + expect(timeout).toBe(300_000); // codex honors the same AI_TIMEOUT_MS override as Claude Code // an explicit model (AI_MODEL, or a `codex:` reviewer id) IS passed through await createCodexAi({ AI_MODEL: "o4-mini" }, ok).run("", { prompt: "x" }); expect(seen.join(" ")).toContain("--model o4-mini"); diff --git a/test/unit/settings-preview.test.ts b/test/unit/settings-preview.test.ts index 23d23bfb6c..4df9b560b7 100644 --- a/test/unit/settings-preview.test.ts +++ b/test/unit/settings-preview.test.ts @@ -60,6 +60,7 @@ function settings(overrides: Partial = {}): RepositorySettin privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, + aiReviewAllAuthors: false, ...overrides, }; } diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 393a587e72..74258f5a51 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1851,6 +1851,7 @@ function repoSettings(repoFullName: string): RepositorySettings { privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, + aiReviewAllAuthors: false, }; } diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index cf183d41bd..b9b58fc1bb 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -1680,6 +1680,7 @@ describe("v2 signal builders", () => { privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, + aiReviewAllAuthors: false, }, }); expect(comment).toContain("Author: `unknown`"); diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index 1bfcfd46d0..693e07f2b7 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -408,6 +408,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off" as const, aiReviewByok: false, + aiReviewAllAuthors: false, }; const collisions = buildCollisionReport(repo.fullName, issues, pullRequests); const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); @@ -460,6 +461,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off" as const, aiReviewByok: false, + aiReviewAllAuthors: false, }; const collisions = buildCollisionReport(repo.fullName, issues, pullRequests); const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); @@ -532,6 +534,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off" as const, aiReviewByok: false, + aiReviewAllAuthors: false, }; const collisions = buildCollisionReport(repo.fullName, issues, pullRequests); const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); @@ -625,6 +628,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off" as const, aiReviewByok: false, + aiReviewAllAuthors: false, }; const undetected = detectGittensorContributor("newbie", currentPr, [currentPr], []); const cachedDetected = detectGittensorContributor("oktofeesh1", currentPr, [currentPr, { ...currentPr, number: 10, mergedAt: "2026-05-01T00:00:00.000Z" }], []); @@ -693,6 +697,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off" as const, aiReviewByok: false, + aiReviewAllAuthors: false, }; const comment = buildPublicPrIntelligenceComment({ repo, pr: currentPr, profile, detection, queueHealth, collisions, preflight, settings }); @@ -805,6 +810,7 @@ describe("world-class backend signals", () => { privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, + aiReviewAllAuthors: false, }, }); expect(publicPreflight.findings.map((finding) => finding.code)).toContain("linked_issue_bounty_historical"); diff --git a/test/unit/unified-comment-parity.test.ts b/test/unit/unified-comment-parity.test.ts index db62c14591..2baf391440 100644 --- a/test/unit/unified-comment-parity.test.ts +++ b/test/unit/unified-comment-parity.test.ts @@ -73,6 +73,7 @@ const settings: RepositorySettings = { privateTrustEnabled: true, aiReviewMode: "off", aiReviewByok: false, + aiReviewAllAuthors: false, }; function buildFixtures() {