Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gittensory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions migrations/0029_ai_review_provider_model.sql
Original file line number Diff line number Diff line change
@@ -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;
92 changes: 92 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -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")}`;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -3781,6 +3854,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (isRepoSettingsPreviewPath(path)) return true;
if (isRepoOnboardingPackPreviewPath(path)) return true;
if (isRepoFocusManifestPath(path)) return true;
if (isRepoAiConfigPath(path)) return true;
if (isRepoCheckBeforeStartPath(path)) return true;
if (isRepoContributorIssueDraftGeneratePath(path)) return true;
if (path === EXTENSION_PULL_CONTEXT_PATH && isExtensionScopedSession(identity)) return true;
Expand Down Expand Up @@ -3811,6 +3885,10 @@ function isRepoFocusManifestPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/focus-manifest(?:\/refresh)?$/.test(path);
}

function isRepoAiConfigPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/ai-(?:review|key)$/.test(path);
}

async function authenticateRequestIdentity(c: ProtectedRouteContext): Promise<AuthIdentity | null> {
const bearer = await authenticatePrivateToken(c.env, extractBearerToken(c.req.header("authorization")));
if (bearer) return bearer;
Expand Down Expand Up @@ -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<Response | { identity: AuthIdentity | null }> {
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,
Expand Down
2 changes: 2 additions & 0 deletions src/config/gittensory-repo-focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -447,6 +451,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMinScore: normalizeQualityGateMinScore(settings.qualityGateMinScore),
aiReviewMode: settings.aiReviewMode ?? "off",
aiReviewByok: settings.aiReviewByok ?? false,
aiReviewProvider: normalizeAiReviewProvider(settings.aiReviewProvider),
aiReviewModel: typeof settings.aiReviewModel === "string" && settings.aiReviewModel.trim() ? settings.aiReviewModel.trim() : null,
autoLabelEnabled: settings.autoLabelEnabled ?? true,
gittensorLabel: settings.gittensorLabel ?? "gittensor",
createMissingLabel: settings.createMissingLabel ?? true,
Expand Down Expand Up @@ -474,6 +480,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMinScore: resolved.qualityGateMinScore,
aiReviewMode: resolved.aiReviewMode,
aiReviewByok: resolved.aiReviewByok,
aiReviewProvider: resolved.aiReviewProvider,
aiReviewModel: resolved.aiReviewModel,
autoLabelEnabled: resolved.autoLabelEnabled,
gittensorLabel: resolved.gittensorLabel,
createMissingLabel: resolved.createMissingLabel,
Expand All @@ -500,6 +508,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMinScore: resolved.qualityGateMinScore,
aiReviewMode: resolved.aiReviewMode,
aiReviewByok: resolved.aiReviewByok,
aiReviewProvider: resolved.aiReviewProvider,
aiReviewModel: resolved.aiReviewModel,
autoLabelEnabled: resolved.autoLabelEnabled,
gittensorLabel: resolved.gittensorLabel,
createMissingLabel: resolved.createMissingLabel,
Expand Down Expand Up @@ -4457,6 +4467,10 @@ function parseGateRuleMode(value: string): RepositorySettings["linkedIssueGateMo
return "advisory";
}

function normalizeAiReviewProvider(value: string | null | undefined): "anthropic" | "openai" | null {
return value === "anthropic" || value === "openai" ? value : null;
}

function normalizeQualityGateMinScore(value: number | null | undefined): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
return Math.max(0, Math.min(100, Math.round(value)));
Expand Down
2 changes: 2 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export const repositorySettings = sqliteTable("repository_settings", {
qualityGateMinScore: integer("quality_gate_min_score"),
aiReviewMode: text("ai_review_mode").notNull().default("off"),
aiReviewByok: integer("ai_review_byok", { mode: "boolean" }).notNull().default(false),
aiReviewProvider: text("ai_review_provider"),
aiReviewModel: text("ai_review_model"),
autoLabelEnabled: integer("auto_label_enabled", { mode: "boolean" }).notNull().default(true),
gittensorLabel: text("gittensor_label").notNull().default("gittensor"),
createMissingLabel: integer("create_missing_label", { mode: "boolean" }).notNull().default(true),
Expand Down
8 changes: 7 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,7 +872,13 @@ export async function runAiReviewForAdvisory(
try {
// BYOK: decrypt the maintainer's provider key only when opted in. Falls back to free Workers AI when
// no key is configured or the encryption secret is unavailable (getDecryptedRepositoryAiKey → null).
const providerKey = args.settings.aiReviewByok ? await getDecryptedRepositoryAiKey(env, args.repoFullName) : null;
// Apply config-as-code provider/model: a declared provider must match the stored key's provider (else
// skip BYOK → Workers-AI fallback); a declared model overrides the stored/default model.
const storedKey = args.settings.aiReviewByok ? await getDecryptedRepositoryAiKey(env, args.repoFullName) : null;
const providerKey =
storedKey && (!args.settings.aiReviewProvider || args.settings.aiReviewProvider === storedKey.provider)
? { provider: storedKey.provider, key: storedKey.key, model: args.settings.aiReviewModel ?? storedKey.model }
: null;
const files = await listPullRequestFiles(env, args.repoFullName, args.pr.number);
const result = await runGittensoryAiReview(env, {
repoFullName: args.repoFullName,
Expand Down
22 changes: 20 additions & 2 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export type FocusManifestGateConfig = {
readinessMinScore: number | null;
aiReviewMode: GateRuleMode | null;
aiReviewByok: boolean | null;
aiReviewProvider: "anthropic" | "openai" | null;
aiReviewModel: string | null;
};

/**
Expand All @@ -47,6 +49,8 @@ export type FocusManifestSettings = Partial<
| "qualityGateMinScore"
| "aiReviewMode"
| "aiReviewByok"
| "aiReviewProvider"
| "aiReviewModel"
| "autoLabelEnabled"
| "gittensorLabel"
| "createMissingLabel"
Expand Down Expand Up @@ -143,6 +147,8 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
readinessMinScore: null,
aiReviewMode: null,
aiReviewByok: null,
aiReviewProvider: null,
aiReviewModel: null,
};

const EMPTY_MANIFEST: FocusManifest = {
Expand Down Expand Up @@ -272,6 +278,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
readinessMinScore: normalizeOptionalScore(readinessRecord?.minScore, "gate.readiness.minScore", warnings),
aiReviewMode: normalizeOptionalGateMode(aiReviewRecord?.mode, "gate.aiReview.mode", warnings),
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),
};
gate.present =
gate.enabled !== null ||
Expand All @@ -280,7 +288,9 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.readinessMode !== null ||
gate.readinessMinScore !== null ||
gate.aiReviewMode !== null ||
gate.aiReviewByok !== null;
gate.aiReviewByok !== null ||
gate.aiReviewProvider !== null ||
gate.aiReviewModel !== null;
return gate;
}

Expand All @@ -300,10 +310,12 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
if (gate.readinessMinScore !== null) readiness.minScore = gate.readinessMinScore;
out.readiness = readiness;
}
if (gate.aiReviewMode !== null || gate.aiReviewByok !== null) {
if (gate.aiReviewMode !== null || gate.aiReviewByok !== null || gate.aiReviewProvider !== null || gate.aiReviewModel !== null) {
const aiReview: Record<string, JsonValue> = {};
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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading