diff --git a/.gittensory.yml b/.gittensory.yml index 60b14dbe42..a4e02486ad 100644 --- a/.gittensory.yml +++ b/.gittensory.yml @@ -45,6 +45,8 @@ gate: # aiReview: # opt-in AI maintainer review (off by default; needs the AI flags enabled) # mode: advisory # block | advisory | off — block only blocks on a dual-model consensus defect # byok: false # use a maintainer Anthropic/OpenAI key for the write-up; consensus stays free Workers AI + # provider: anthropic # anthropic | openai — which BYOK provider (the secret key is set via the dashboard, never here) + # model: claude-3-5-sonnet-latest # optional model override for the BYOK write-up publicNotes: - Prefer backend Workers, MCP, GitHub App, registry, and scoring work when scope allows. diff --git a/migrations/0029_ai_review_provider_model.sql b/migrations/0029_ai_review_provider_model.sql new file mode 100644 index 0000000000..cd2652fd1d --- /dev/null +++ b/migrations/0029_ai_review_provider_model.sql @@ -0,0 +1,4 @@ +-- Config-as-code BYOK provider/model for the AI review (the secret key stays in repository_ai_keys, +-- encrypted; these are the non-secret choices, settable via .gittensory.yml or the maintainer dashboard). +ALTER TABLE repository_settings ADD COLUMN ai_review_provider TEXT; +ALTER TABLE repository_settings ADD COLUMN ai_review_model TEXT; diff --git a/src/api/routes.ts b/src/api/routes.ts index 9e688fd969..dd05f9f25b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -515,6 +515,8 @@ const repositorySettingsSchema = z.object({ qualityGateMinScore: z.number().int().min(0).max(100).nullable().optional(), aiReviewMode: z.enum(["off", "advisory", "block"]).default("off"), aiReviewByok: z.boolean().default(false), + aiReviewProvider: z.enum(["anthropic", "openai"]).nullable().optional(), + aiReviewModel: z.string().trim().min(1).max(120).nullable().optional(), autoLabelEnabled: z.boolean().default(true), gittensorLabel: z.string().trim().min(1).max(50).default("gittensor"), createMissingLabel: z.boolean().default(true), @@ -539,6 +541,15 @@ const repositoryAiKeySchema = z.object({ model: z.string().trim().min(1).max(120).nullable().optional(), }); +// Maintainer-settable AI-review config (the non-secret subset of settings). The secret key is set +// separately via the ai-key route; never here. +const repositoryAiReviewSchema = z.object({ + mode: z.enum(["off", "advisory", "block"]), + byok: z.boolean().default(false), + provider: z.enum(["anthropic", "openai"]).nullable().optional(), + model: z.string().trim().min(1).max(120).nullable().optional(), +}); + const contributorIssueDraftGenerateSchema = z.object({ dryRun: z.boolean().optional().default(true), create: z.boolean().optional().default(false), @@ -1765,6 +1776,66 @@ export function createApp() { return c.json(await getRepositorySettings(c.env, fullName)); }); + // Maintainer self-serve AI-review config (non-secret: mode/byok/provider/model). Session-authenticated + + // scoped to repos the maintainer owns/maintains. The secret provider key goes through the ai-key route. + // Merges onto current settings so unrelated settings are preserved. + app.put("/v1/repos/:owner/:repo/ai-review", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoMaintainer(c, fullName); + if (gate instanceof Response) return gate; + const parsed = repositoryAiReviewSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: "invalid_ai_review_config", issues: parsed.error.issues }, 400); + const current = await getRepositorySettings(c.env, fullName); + const updated = await upsertRepositorySettings(c.env, { + ...current, + aiReviewMode: parsed.data.mode, + aiReviewByok: parsed.data.byok, + aiReviewProvider: parsed.data.provider, + aiReviewModel: parsed.data.model, + }); + // getRepositorySettings normalizes these to a concrete value or null (never undefined). + return c.json({ + aiReviewMode: updated.aiReviewMode, + aiReviewByok: updated.aiReviewByok, + aiReviewProvider: updated.aiReviewProvider ?? null, + aiReviewModel: updated.aiReviewModel ?? null, + }); + }); + + // Maintainer self-serve BYOK provider key. Write-only + maintainer-scoped. GET returns only + // {configured, provider, last4, model}; the key is never returned, logged, or surfaced. + app.get("/v1/repos/:owner/:repo/ai-key", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoMaintainer(c, fullName); + if (gate instanceof Response) return gate; + return c.json(await getRepositoryAiKeyStatus(c.env, fullName)); + }); + + app.post("/v1/repos/:owner/:repo/ai-key", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoMaintainer(c, fullName); + if (gate instanceof Response) return gate; + const parsed = repositoryAiKeySchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: "invalid_ai_key", issues: parsed.error.issues }, 400); + const createdBy = gate.identity?.kind === "session" ? gate.identity.actor : null; + try { + return c.json(await upsertRepositoryAiKey(c.env, { repoFullName: fullName, provider: parsed.data.provider, key: parsed.data.key, model: parsed.data.model ?? null, createdBy })); + } catch (error) { + if (error instanceof Error && error.message === "missing_encryption_secret") { + return c.json({ error: "encryption_unavailable", detail: "Key storage is not configured on the server." }, 503); + } + throw error; + } + }); + + app.delete("/v1/repos/:owner/:repo/ai-key", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoMaintainer(c, fullName); + if (gate instanceof Response) return gate; + await deleteRepositoryAiKey(c.env, fullName); + return c.json({ configured: false }); + }); + app.post("/v1/repos/:owner/:repo/settings-preview", async (c) => { const identity = await authenticateRequestIdentity(c); const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; @@ -2461,6 +2532,8 @@ export function createApp() { qualityGateMinScore: parsed.data.qualityGateMinScore, aiReviewMode: parsed.data.aiReviewMode, aiReviewByok: parsed.data.aiReviewByok, + aiReviewProvider: parsed.data.aiReviewProvider, + aiReviewModel: parsed.data.aiReviewModel, autoLabelEnabled: parsed.data.autoLabelEnabled, gittensorLabel: parsed.data.gittensorLabel, createMissingLabel: parsed.data.createMissingLabel, @@ -3781,6 +3854,7 @@ function canSessionAccessPath(env: Env, identity: Extract { const bearer = await authenticatePrivateToken(c.env, extractBearerToken(c.req.header("authorization"))); if (bearer) return bearer; @@ -3884,6 +3962,20 @@ async function requireSessionRepoAccess( return c.json({ error: "forbidden_repo" }, 403); } +/** Gate a maintainer-scoped repo route: requires a maintainer/owner/operator role and, for session + * callers, access to that specific repo. Returns the resolved identity, or a Response to short-circuit. */ +async function requireRepoMaintainer(c: ProtectedRouteContext, fullName: string): Promise { + const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]); + if (forbidden) return forbidden; + const identity = await authenticateRequestIdentity(c); + if (identity?.kind === "session") { + const repo = await getRepository(c.env, fullName); + const repoForbidden = await requireSessionRepoAccess(c, identity, fullName, repo); + if (repoForbidden) return repoForbidden; + } + return { identity }; +} + async function skippedPrAuditRepoScope( c: ProtectedRouteContext, identity: AuthIdentity, diff --git a/src/config/gittensory-repo-focus-manifest.ts b/src/config/gittensory-repo-focus-manifest.ts index 7b5abf7c5a..b36602a0cb 100644 --- a/src/config/gittensory-repo-focus-manifest.ts +++ b/src/config/gittensory-repo-focus-manifest.ts @@ -49,6 +49,8 @@ gate: # aiReview: # opt-in AI maintainer review (off by default; needs the AI flags enabled) # mode: advisory # block | advisory | off — block only blocks on a dual-model consensus defect # byok: false # use a maintainer Anthropic/OpenAI key for the write-up; consensus stays free Workers AI + # provider: anthropic # anthropic | openai — which BYOK provider (the secret key is set via the dashboard, never here) + # model: claude-3-5-sonnet-latest # optional model override for the BYOK write-up publicNotes: - Prefer backend Workers, MCP, GitHub App, registry, and scoring work when scope allows. diff --git a/src/db/repositories.ts b/src/db/repositories.ts index d08aa0eb55..c4f3318568 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -393,6 +393,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise qualityGateMinScore: null, aiReviewMode: "off", aiReviewByok: false, + aiReviewProvider: null, + aiReviewModel: null, autoLabelEnabled: true, gittensorLabel: "gittensor", createMissingLabel: true, @@ -418,6 +420,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore), aiReviewMode: parseGateRuleMode(row.aiReviewMode), aiReviewByok: row.aiReviewByok, + aiReviewProvider: normalizeAiReviewProvider(row.aiReviewProvider), + aiReviewModel: row.aiReviewModel ?? null, autoLabelEnabled: row.autoLabelEnabled, gittensorLabel: row.gittensorLabel, createMissingLabel: row.createMissingLabel, @@ -447,6 +451,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial = {}; 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; out.aiReview = aiReview; } return out; @@ -357,6 +369,10 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) if (qualityGateMinScore !== null) out.qualityGateMinScore = qualityGateMinScore; const aiReviewMode = normalizeOptionalGateMode(r.aiReviewMode, "settings.aiReviewMode", warnings); if (aiReviewMode !== null) out.aiReviewMode = aiReviewMode; + const aiReviewProvider = normalizeOptionalEnum(r.aiReviewProvider, "settings.aiReviewProvider", ["anthropic", "openai"] as const, warnings); + if (aiReviewProvider !== null) out.aiReviewProvider = aiReviewProvider; + const aiReviewModel = normalizeOptionalString(r.aiReviewModel, "settings.aiReviewModel", warnings); + if (aiReviewModel !== null) out.aiReviewModel = aiReviewModel; const gittensorLabel = normalizeOptionalString(r.gittensorLabel, "settings.gittensorLabel", warnings); if (gittensorLabel !== null) out.gittensorLabel = gittensorLabel; const publicSurface = normalizeOptionalEnum(r.publicSurface, "settings.publicSurface", ["off", "comment_and_label", "comment_only", "label_only"] as const, warnings); @@ -441,6 +457,8 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes if (gate.readinessMinScore !== null) effective.qualityGateMinScore = gate.readinessMinScore; if (gate.aiReviewMode !== null) effective.aiReviewMode = gate.aiReviewMode; if (gate.aiReviewByok !== null) effective.aiReviewByok = gate.aiReviewByok; + if (gate.aiReviewProvider !== null) effective.aiReviewProvider = gate.aiReviewProvider; + if (gate.aiReviewModel !== null) effective.aiReviewModel = gate.aiReviewModel; return effective; } diff --git a/src/types.ts b/src/types.ts index 8bcd0c6a3a..20616f25da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -381,6 +381,13 @@ export type RepositorySettings = { * consensus blocker always uses the free Workers-AI model pair regardless, so BYOK never changes who * can be blocked. Default false. */ aiReviewByok: boolean; + /** Config-as-code BYOK provider for the advisory write-up. `null` = use the configured key's own + * provider. When set, it must match the stored key's provider or BYOK is skipped (Workers-AI fallback). + * The secret key itself is never here — only via the encrypted key store. */ + aiReviewProvider?: "anthropic" | "openai" | null | undefined; + /** 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; autoLabelEnabled: boolean; gittensorLabel: string; createMissingLabel: boolean; diff --git a/test/unit/ai-review-advisory.test.ts b/test/unit/ai-review-advisory.test.ts index 79a1708fe6..2db40c026c 100644 --- a/test/unit/ai-review-advisory.test.ts +++ b/test/unit/ai-review-advisory.test.ts @@ -166,6 +166,42 @@ describe("runAiReviewForAdvisory", () => { expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.anthropic.com/v1/messages"); }); + it("applies the config-as-code model override and sends it to the provider", async () => { + const env = createTestEnv({ AI: { run: async () => ({ response: notesOnlyJson() }) } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "100000", TOKEN_ENCRYPTION_SECRET: "advisory-test-encryption-secret-32bytes" }); + await upsertRepositoryAiKey(env, { repoFullName: "acme/widgets", provider: "anthropic", key: "sk-ant-byok-key-9999", model: "claude-stored" }); + const fetchMock = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ content: [{ type: "text", text: notesOnlyJson() }] }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + await runAiReviewForAdvisory(env, { + settings: { aiReviewMode: "advisory", aiReviewByok: true, aiReviewProvider: "anthropic", aiReviewModel: "claude-from-yml" } as RepositorySettings, + advisory: advisory(), + repoFullName: "acme/widgets", + pr, + author: "alice", + confirmedContributor: true, + }); + // The yml model override wins over the stored key's model. + expect(JSON.parse(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body)).model).toBe("claude-from-yml"); + }); + + it("skips BYOK (falls back to Workers AI) when the declared provider doesn't match the stored key", async () => { + const run = vi.fn(async () => ({ response: notesOnlyJson() })); + const env = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "100000", TOKEN_ENCRYPTION_SECRET: "advisory-test-encryption-secret-32bytes" }); + await upsertRepositoryAiKey(env, { repoFullName: "acme/widgets", provider: "anthropic", key: "sk-ant-byok-key-9999", model: null }); + const fetchMock = vi.fn(async () => new Response("should not be called", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const result = await runAiReviewForAdvisory(env, { + settings: { aiReviewMode: "advisory", aiReviewByok: true, aiReviewProvider: "openai" } as RepositorySettings, // declared openai, stored anthropic → mismatch + advisory: advisory(), + repoFullName: "acme/widgets", + pr, + author: "alice", + confirmedContributor: true, + }); + expect(result?.notes).toContain("Add a test."); // produced via Workers AI fallback + expect(fetchMock).not.toHaveBeenCalled(); // no provider call + expect(run).toHaveBeenCalled(); // Workers AI used instead + }); + it("is fail-safe: a thrown error (e.g. broken DB) yields no finding and no notes", async () => { const adv = advisory(); const env = aiEnv(async () => ({ response: defectJson() })); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 1b58c1a356..fd781b543e 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -400,7 +400,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, enabled: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, aiReviewMode: null, aiReviewByok: null }, + gate: { present: false, enabled: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {} }, warnings: [], @@ -688,7 +688,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, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, aiReviewMode: null, aiReviewByok: null }); + expect(m.gate).toEqual({ present: true, enabled: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null }); }); it("parses gate.enabled (on/off) and ignores non-boolean values with a warning", () => { @@ -765,6 +765,18 @@ describe("parseFocusManifest gate config", () => { expect(parseFocusManifest({ gate: { aiReview: ["nope"] } }).warnings.some((w) => /gate\.aiReview" must be a mapping/.test(w))).toBe(true); expect(parseFocusManifest({ gate: { aiReview: { mode: "loud" } } }).warnings.some((w) => /gate\.aiReview\.mode/.test(w))).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"); + expect(m.gate.aiReviewModel).toBe("claude-3-5-sonnet-latest"); + expect(parseFocusManifest({ gate: gateConfigToJson(m.gate) }).gate).toEqual(m.gate); // round-trips + expect(parseFocusManifest({ gate: { aiReview: { provider: "grok" } } }).warnings.some((w) => /gate\.aiReview\.provider/.test(w))).toBe(true); + // resolveEffectiveSettings carries provider/model through (gate alias). + const eff = resolveEffectiveSettings({ aiReviewProvider: null, aiReviewModel: null } as unknown as RepositorySettings, m); + expect(eff.aiReviewProvider).toBe("anthropic"); + expect(eff.aiReviewModel).toBe("claude-3-5-sonnet-latest"); + }); }); describe("parseFocusManifest settings override + resolveEffectiveSettings", () => { diff --git a/test/unit/routes-ai-byok.test.ts b/test/unit/routes-ai-byok.test.ts new file mode 100644 index 0000000000..186567e576 --- /dev/null +++ b/test/unit/routes-ai-byok.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { getRepositorySettings, upsertInstallation, upsertRepositorySettings, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +const SECRET = "routes-byok-encryption-secret-at-least-32b"; +const REPO = "acme/widgets"; + +function apiHeaders(env: Env): Record { + return { authorization: `Bearer ${env.GITTENSORY_API_TOKEN}`, "content-type": "application/json" }; +} + +async function seedRepo(env: Env, owner: string, name: string, installationId: number): Promise { + await upsertInstallation(env, { + installation: { id: installationId, account: { login: owner, id: installationId, type: "User" }, repository_selection: "selected", permissions: { metadata: "read" }, events: ["repository"] }, + }); + await upsertRepositoryFromGitHub(env, { name, full_name: `${owner}/${name}`, private: false, owner: { login: owner } }, installationId); + await env.DB.prepare("UPDATE repositories SET is_registered = 1 WHERE full_name = ?").bind(`${owner}/${name}`).run(); +} + +describe("maintainer AI-review config route", () => { + it("sets mode/byok/provider/model and preserves unrelated settings", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await upsertRepositorySettings(env, { repoFullName: REPO, gateCheckMode: "enabled", gittensorLabel: "custom-label" }); + 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" }) }, + env, + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ aiReviewMode: "block", aiReviewByok: true, aiReviewProvider: "anthropic", aiReviewModel: "claude-3-5-sonnet-latest" }); + const settings = await getRepositorySettings(env, REPO); + expect(settings.aiReviewMode).toBe("block"); + expect(settings.gateCheckMode).toBe("enabled"); // preserved + expect(settings.gittensorLabel).toBe("custom-label"); // preserved + }); + + it("accepts a config without provider/model (stored as null)", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const res = await app.request(`/v1/repos/${REPO}/ai-review`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "advisory", byok: false }) }, env); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ aiReviewMode: "advisory", aiReviewByok: false, aiReviewProvider: null, aiReviewModel: null }); + }); + + it("rejects an invalid AI-review config", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const res = await app.request(`/v1/repos/${REPO}/ai-review`, { method: "PUT", headers: apiHeaders(env), body: JSON.stringify({ mode: "loud" }) }, env); + expect(res.status).toBe(400); + }); +}); + +describe("maintainer BYOK key route", () => { + it("POST stores, GET returns secret-free status, DELETE removes — key never echoed", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const post = await app.request(`/v1/repos/${REPO}/ai-key`, { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ provider: "anthropic", key: "sk-ant-route-key-7777", model: "claude-3-5-sonnet-latest" }) }, env); + expect(post.status).toBe(200); + const body = await post.json(); + expect(body).toMatchObject({ configured: true, provider: "anthropic", last4: "7777" }); + expect(JSON.stringify(body)).not.toContain("sk-ant"); + + const get = await app.request(`/v1/repos/${REPO}/ai-key`, { headers: apiHeaders(env) }, env); + expect(await get.json()).toMatchObject({ configured: true, last4: "7777", model: "claude-3-5-sonnet-latest" }); + + const del = await app.request(`/v1/repos/${REPO}/ai-key`, { method: "DELETE", headers: apiHeaders(env) }, env); + expect(await del.json()).toEqual({ configured: false }); + expect(await (await app.request(`/v1/repos/${REPO}/ai-key`, { headers: apiHeaders(env) }, env)).json()).toEqual({ configured: false }); + }); + + it("rejects an invalid key payload (400)", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + const res = await app.request(`/v1/repos/${REPO}/ai-key`, { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ provider: "anthropic", key: "short" }) }, env); + expect(res.status).toBe(400); + }); + + it("reports 503 when key storage (encryption secret) is unavailable", async () => { + const app = createApp(); + const env = createTestEnv({}); + const res = await app.request(`/v1/repos/${REPO}/ai-key`, { method: "POST", headers: apiHeaders(env), body: JSON.stringify({ provider: "openai", key: "sk-openai-valid-key-123456" }) }, env); + expect(res.status).toBe(503); + expect(await res.json()).toMatchObject({ error: "encryption_unavailable" }); + }); +}); + +describe("maintainer route authz (session-scoped)", () => { + const OWNED = "/v1/repos/repo-owner/owned-repo"; + + // Role resolution (loadControlPanelRoleSummary) makes a miner-detection fetch; stub it so session role + // derivation is deterministic in tests. + afterEach(() => vi.unstubAllGlobals()); + function stubMinerFetch() { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("gittensor.io")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + } + + it("rejects unauthenticated access on every method", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET, ADMIN_GITHUB_LOGINS: "" }); + await seedRepo(env, "repo-owner", "owned-repo", 201); + expect((await app.request(`${OWNED}/ai-key`, {}, env)).status).toBe(401); + expect((await app.request(`${OWNED}/ai-key`, { method: "POST", body: "{}" }, env)).status).toBe(401); + expect((await app.request(`${OWNED}/ai-key`, { method: "DELETE" }, env)).status).toBe(401); + expect((await app.request(`${OWNED}/ai-review`, { method: "PUT", body: "{}" }, env)).status).toBe(401); + }); + + it("allows the repo owner via session to write the AI-review config", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET, ADMIN_GITHUB_LOGINS: "" }); + await seedRepo(env, "repo-owner", "owned-repo", 201); + stubMinerFetch(); + const { token } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 201 }); + const res = await app.request(`${OWNED}/ai-review`, { method: "PUT", headers: { cookie: `gittensory_session=${token}`, "content-type": "application/json" }, body: JSON.stringify({ mode: "advisory", byok: true, provider: "anthropic" }) }, env); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ aiReviewMode: "advisory", aiReviewProvider: "anthropic" }); + }); + + it("allows the repo owner via session to set a BYOK key", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET, ADMIN_GITHUB_LOGINS: "" }); + await seedRepo(env, "repo-owner", "owned-repo", 201); + stubMinerFetch(); + const { token } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 201 }); + const res = await app.request(`${OWNED}/ai-key`, { method: "POST", headers: { cookie: `gittensory_session=${token}`, "content-type": "application/json" }, body: JSON.stringify({ provider: "anthropic", key: "sk-ant-owner-key-4242" }) }, env); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ configured: true, provider: "anthropic", last4: "4242" }); + }); + + it("forbids a session with no role for the repo on every AI route (403)", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET, ADMIN_GITHUB_LOGINS: "" }); + await seedRepo(env, "repo-owner", "owned-repo", 201); + const { token } = await createSessionForGitHubUser(env, { login: "someone-else", id: 999 }); + const cookie = `gittensory_session=${token}`; + const json = { cookie, "content-type": "application/json" }; + expect((await app.request(`${OWNED}/ai-key`, { headers: { cookie } }, env)).status).toBe(403); + expect((await app.request(`${OWNED}/ai-key`, { method: "POST", headers: json, body: JSON.stringify({ provider: "anthropic", key: "sk-ant-nope-000000000" }) }, env)).status).toBe(403); + expect((await app.request(`${OWNED}/ai-key`, { method: "DELETE", headers: { cookie } }, env)).status).toBe(403); + expect((await app.request(`${OWNED}/ai-review`, { method: "PUT", headers: json, body: JSON.stringify({ mode: "advisory", byok: false }) }, env)).status).toBe(403); + }); + + it("forbids a maintainer of one repo from configuring a different repo (cross-repo)", async () => { + const app = createApp(); + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET, ADMIN_GITHUB_LOGINS: "" }); + await seedRepo(env, "repo-owner", "owned-repo", 201); + await seedRepo(env, "other-owner", "other-repo", 202); + stubMinerFetch(); + const { token } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 201 }); + const res = await app.request("/v1/repos/other-owner/other-repo/ai-key", { headers: { cookie: `gittensory_session=${token}` } }, env); + expect(res.status).toBe(403); + }); +});