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
3 changes: 3 additions & 0 deletions .gittensory.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Gittensory repo focus manifest — machine-readable contributor policy for this project.

Check notice on line 1 in .gittensory.yml

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in .gittensory.yml

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
# Private maintainerNotes stay in authenticated API surfaces only.

source: repo_file
Expand Down Expand Up @@ -42,6 +42,9 @@
readiness:
mode: advisory # block | advisory | off — readiness-score floor
minScore: 60
# 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

publicNotes:
- Prefer backend Workers, MCP, GitHub App, registry, and scoring work when scope allows.
Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Contributing

Check notice on line 1 in CONTRIBUTING.md

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in CONTRIBUTING.md

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Gittensory is a Cloudflare Workers, TanStack Start, GitHub App, and MCP project for
Gittensor OSS contribution intelligence. Contributions need to protect that scope: private
Expand Down Expand Up @@ -37,7 +37,10 @@
- Auto-closing, auto-merging, rewriting contributor work, or applying labels outside the explicit
confirmed-miner GitHub App policy.
- Storing contributor GitHub PATs or adding non-GitHub identity providers. Browser auth is GitHub
OAuth; CLI/MCP auth is GitHub Device Flow.
OAuth; CLI/MCP auth is GitHub Device Flow. (A maintainer's own optional AI-provider key for BYOK AI
review is a different credential class — it is the repo owner's LLM-inference key, not a GitHub
identity credential — and is allowed: it is opt-in, encrypted at rest, write-only, and never returned
or logged.)
- Large dependency major upgrades bundled with unrelated product changes.
- Changelog edits in ordinary feature/fix PRs. Changelogs are updated during release prep.
- Low-effort reward-farming changes, spam, generated bulk edits, or PRs that do not explain the
Expand Down
2 changes: 2 additions & 0 deletions migrations/0026_ai_review_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE repository_settings ADD COLUMN ai_review_mode TEXT NOT NULL DEFAULT 'off';

Check notice on line 1 in migrations/0026_ai_review_settings.sql

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in migrations/0026_ai_review_settings.sql

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
ALTER TABLE repository_settings ADD COLUMN ai_review_byok INTEGER NOT NULL DEFAULT 0;
12 changes: 12 additions & 0 deletions migrations/0027_repository_ai_keys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS repository_ai_keys (

Check notice on line 1 in migrations/0027_repository_ai_keys.sql

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in migrations/0027_repository_ai_keys.sql

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
repo_full_name TEXT PRIMARY KEY,
provider TEXT NOT NULL,
ciphertext TEXT NOT NULL,
iv TEXT NOT NULL,
key_version INTEGER NOT NULL DEFAULT 1,
model TEXT,
last4 TEXT NOT NULL,
created_by TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
50 changes: 50 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Hono, type Context } from "hono";

Check notice on line 1 in src/api/routes.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in src/api/routes.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
import { z } from "zod";
import { analyzePRQueue, type AuthorRole, type ChecksStatus } from "../queue-intelligence";
import { completeGitHubWebOAuth, createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../auth/github-oauth";
Expand Down Expand Up @@ -89,6 +89,9 @@
upsertContributorEvidence,
upsertContributorScoringProfile,
upsertRepositorySettings,
getRepositoryAiKeyStatus,
upsertRepositoryAiKey,
deleteRepositoryAiKey,
} from "../db/repositories";
import {
backfillOpenPullRequestDetails,
Expand Down Expand Up @@ -509,6 +512,8 @@
duplicatePrGateMode: z.enum(["off", "advisory", "block"]).default("block"),
qualityGateMode: z.enum(["off", "advisory", "block"]).default("advisory"),
qualityGateMinScore: z.number().int().min(0).max(100).nullable().optional(),
aiReviewMode: z.enum(["off", "advisory", "block"]).default("off"),
aiReviewByok: z.boolean().default(false),
autoLabelEnabled: z.boolean().default(true),
gittensorLabel: z.string().trim().min(1).max(50).default("gittensor"),
createMissingLabel: z.boolean().default(true),
Expand All @@ -525,6 +530,14 @@
.default(DEFAULT_COMMAND_AUTHORIZATION_POLICY),
});

// Maintainer BYOK provider key. Write-only: the key is encrypted at rest and never returned. A loose
// shape check (sk-ant-… / sk-…) catches obvious paste errors without coupling to provider key formats.
const repositoryAiKeySchema = z.object({
provider: z.enum(["anthropic", "openai"]),
key: z.string().trim().min(20).max(400),
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 @@ -2445,6 +2458,8 @@
duplicatePrGateMode: parsed.data.duplicatePrGateMode,
qualityGateMode: parsed.data.qualityGateMode,
qualityGateMinScore: parsed.data.qualityGateMinScore,
aiReviewMode: parsed.data.aiReviewMode,
aiReviewByok: parsed.data.aiReviewByok,
autoLabelEnabled: parsed.data.autoLabelEnabled,
gittensorLabel: parsed.data.gittensorLabel,
createMissingLabel: parsed.data.createMissingLabel,
Expand All @@ -2458,6 +2473,41 @@
);
});

// Maintainer BYOK provider key. GET returns secret-free status only; POST stores it encrypted at rest;
// DELETE removes it. The plaintext key is never logged and never returned.
app.get("/v1/internal/repos/:owner/:repo/ai-key", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
return c.json(await getRepositoryAiKeyStatus(c.env, fullName));
});

app.post("/v1/internal/repos/:owner/:repo/ai-key", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = repositoryAiKeySchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_ai_key", issues: parsed.error.issues }, 400);
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
try {
const status = await upsertRepositoryAiKey(c.env, {
repoFullName: fullName,
provider: parsed.data.provider,
key: parsed.data.key,
model: parsed.data.model ?? null,
});
return c.json(status);
} catch (error) {
// The only expected throw is a missing encryption secret — never echo key material in the error.
if (error instanceof Error && error.message === "missing_encryption_secret") {
return c.json({ error: "encryption_unavailable", detail: "TOKEN_ENCRYPTION_SECRET is not configured." }, 503);
}
throw error;
}
});

app.delete("/v1/internal/repos/:owner/:repo/ai-key", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
await deleteRepositoryAiKey(c.env, fullName);
return c.json({ configured: false });
});

app.get("/v1/internal/repos/:owner/:repo/contribution-policy", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const focusManifest = await loadRepoFocusManifest(c.env, fullName, { fetcher: async () => null });
Expand Down
3 changes: 3 additions & 0 deletions src/config/gittensory-repo-focus-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**

Check notice on line 1 in src/config/gittensory-repo-focus-manifest.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in src/config/gittensory-repo-focus-manifest.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
* Bundled fallback for JSONbored/gittensory when the repo file is not yet reachable
* (local dev, pre-merge branches). Keep aligned with `.gittensory.yml` at repo root.
*/
Expand Down Expand Up @@ -46,6 +46,9 @@
readiness:
mode: advisory # block | advisory | off — readiness-score floor
minScore: 60
# 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

publicNotes:
- Prefer backend Workers, MCP, GitHub App, registry, and scoring work when scope allows.
Expand Down
88 changes: 87 additions & 1 deletion src/db/repositories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq, gte, inArray, not, or, sql, type SQL } from "drizzle-orm";

Check notice on line 1 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
import { getDb } from "./client";
import {
advisories,
Expand Down Expand Up @@ -42,6 +42,7 @@
repoSnapshots,
repoSyncSegments,
repoSyncState,
repositoryAiKeys,
repositorySettings,
scorePreviews,
scoringModelSnapshots,
Expand Down Expand Up @@ -139,7 +140,7 @@
import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api";
import { classifyMcpClientVersion, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION } from "../services/mcp-compatibility";
import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization";
import { sha256Hex } from "../utils/crypto";
import { decryptSecret, encryptSecret, sha256Hex } from "../utils/crypto";
import { jsonString, nowIso, parseJson, repoParts } from "../utils/json";

const MAX_STORED_BODY_CHARS = 4000;
Expand Down Expand Up @@ -390,6 +391,8 @@
duplicatePrGateMode: "block",
qualityGateMode: "advisory",
qualityGateMinScore: null,
aiReviewMode: "off",
aiReviewByok: false,
autoLabelEnabled: true,
gittensorLabel: "gittensor",
createMissingLabel: true,
Expand All @@ -413,6 +416,8 @@
duplicatePrGateMode: parseGateRuleMode(row.duplicatePrGateMode),
qualityGateMode: parseGateRuleMode(row.qualityGateMode),
qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore),
aiReviewMode: parseGateRuleMode(row.aiReviewMode),
aiReviewByok: row.aiReviewByok,
autoLabelEnabled: row.autoLabelEnabled,
gittensorLabel: row.gittensorLabel,
createMissingLabel: row.createMissingLabel,
Expand Down Expand Up @@ -440,6 +445,8 @@
duplicatePrGateMode: settings.duplicatePrGateMode ?? "block",
qualityGateMode: settings.qualityGateMode ?? "advisory",
qualityGateMinScore: normalizeQualityGateMinScore(settings.qualityGateMinScore),
aiReviewMode: settings.aiReviewMode ?? "off",
aiReviewByok: settings.aiReviewByok ?? false,
autoLabelEnabled: settings.autoLabelEnabled ?? true,
gittensorLabel: settings.gittensorLabel ?? "gittensor",
createMissingLabel: settings.createMissingLabel ?? true,
Expand All @@ -465,6 +472,8 @@
duplicatePrGateMode: resolved.duplicatePrGateMode,
qualityGateMode: resolved.qualityGateMode,
qualityGateMinScore: resolved.qualityGateMinScore,
aiReviewMode: resolved.aiReviewMode,
aiReviewByok: resolved.aiReviewByok,
autoLabelEnabled: resolved.autoLabelEnabled,
gittensorLabel: resolved.gittensorLabel,
createMissingLabel: resolved.createMissingLabel,
Expand All @@ -489,6 +498,8 @@
duplicatePrGateMode: resolved.duplicatePrGateMode,
qualityGateMode: resolved.qualityGateMode,
qualityGateMinScore: resolved.qualityGateMinScore,
aiReviewMode: resolved.aiReviewMode,
aiReviewByok: resolved.aiReviewByok,
autoLabelEnabled: resolved.autoLabelEnabled,
gittensorLabel: resolved.gittensorLabel,
createMissingLabel: resolved.createMissingLabel,
Expand All @@ -504,6 +515,81 @@
return getRepositorySettings(env, resolved.repoFullName);
}

// ─── Maintainer BYOK provider keys ──────────────────────────────────────────────────────────────

export type AiKeyProvider = "anthropic" | "openai";

/** Public, secret-free status of a repo's BYOK key. NEVER includes the key or ciphertext. */
export type RepositoryAiKeyStatus =
| { configured: true; provider: AiKeyProvider; last4: string; model: string | null }
| { configured: false };

/** A decrypted provider key for use at AI-call time only. Never returned from the API, never logged. */
export type DecryptedRepositoryAiKey = { provider: AiKeyProvider; key: string; model: string | null };

function normalizeAiKeyProvider(value: string): AiKeyProvider {
return value === "openai" ? "openai" : "anthropic";
}

/** Read the secret-free status of a repo's configured BYOK key (for the dashboard/API). */
export async function getRepositoryAiKeyStatus(env: Env, fullName: string): Promise<RepositoryAiKeyStatus> {
const db = getDb(env.DB);
const [row] = await db.select().from(repositoryAiKeys).where(eq(repositoryAiKeys.repoFullName, fullName)).limit(1);
if (!row) return { configured: false };
return { configured: true, provider: normalizeAiKeyProvider(row.provider), last4: row.last4, model: row.model ?? null };
}

/**
* Store (or replace) a repo's BYOK provider key, encrypted at rest. Returns the secret-free status.
* Throws `missing_encryption_secret` when TOKEN_ENCRYPTION_SECRET is not configured — callers must
* surface that rather than store a key in the clear.
*/
export async function upsertRepositoryAiKey(
env: Env,
input: { repoFullName: string; provider: AiKeyProvider; key: string; model?: string | null; createdBy?: string | null },
): Promise<RepositoryAiKeyStatus> {
const secret = env.TOKEN_ENCRYPTION_SECRET;
if (!secret) throw new Error("missing_encryption_secret");
const trimmedKey = input.key.trim();
const { ciphertext, iv, version } = await encryptSecret(trimmedKey, secret);
const last4 = trimmedKey.slice(-4);
const model = input.model?.trim() ? input.model.trim() : null;
const db = getDb(env.DB);
await db
.insert(repositoryAiKeys)
.values({ repoFullName: input.repoFullName, provider: input.provider, ciphertext, iv, keyVersion: version, model, last4, createdBy: input.createdBy ?? null, updatedAt: nowIso() })
.onConflictDoUpdate({
target: repositoryAiKeys.repoFullName,
set: { provider: input.provider, ciphertext, iv, keyVersion: version, model, last4, createdBy: input.createdBy ?? null, updatedAt: nowIso() },
});
return { configured: true, provider: input.provider, last4, model };
}

/** Remove a repo's BYOK key. */
export async function deleteRepositoryAiKey(env: Env, fullName: string): Promise<void> {
const db = getDb(env.DB);
await db.delete(repositoryAiKeys).where(eq(repositoryAiKeys.repoFullName, fullName));
}

/**
* Decrypt a repo's BYOK key for an AI call. Returns null when no key is configured OR the encryption
* secret is unavailable OR decryption fails — so the caller silently falls back to free Workers AI and
* a misconfiguration never blocks the review. The plaintext key must be used immediately and never cached.
*/
export async function getDecryptedRepositoryAiKey(env: Env, fullName: string): Promise<DecryptedRepositoryAiKey | null> {
const secret = env.TOKEN_ENCRYPTION_SECRET;
if (!secret) return null;
const db = getDb(env.DB);
const [row] = await db.select().from(repositoryAiKeys).where(eq(repositoryAiKeys.repoFullName, fullName)).limit(1);
if (!row) return null;
try {
const key = await decryptSecret(row.ciphertext, row.iv, secret);
return { provider: normalizeAiKeyProvider(row.provider), key, model: row.model ?? null };
} catch {
return null;
}
}

export async function upsertRepoSyncState(env: Env, state: RepoSyncStateRecord): Promise<void> {
const db = getDb(env.DB);
await db
Expand Down
18 changes: 18 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { index, integer, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";

Check notice on line 1 in src/db/schema.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in src/db/schema.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
// Timestamp columns use a drizzle $defaultFn so an insert that omits the column gets a real ISO-8601
// timestamp. A static `.default("CURRENT_TIMESTAMP")` would make drizzle inject the literal STRING
// "CURRENT_TIMESTAMP" (it applies static defaults client-side, never reaching SQLite's CURRENT_TIMESTAMP),
Expand Down Expand Up @@ -50,6 +50,8 @@
duplicatePrGateMode: text("duplicate_pr_gate_mode").notNull().default("block"),
qualityGateMode: text("quality_gate_mode").notNull().default("advisory"),
qualityGateMinScore: integer("quality_gate_min_score"),
aiReviewMode: text("ai_review_mode").notNull().default("off"),
aiReviewByok: integer("ai_review_byok", { mode: "boolean" }).notNull().default(false),
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 All @@ -63,6 +65,22 @@
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});

// Maintainer BYOK provider keys (Anthropic/OpenAI), encrypted at rest with AES-256-GCM. Isolated in its
// own table so the ciphertext is NEVER serialized by the repository-settings GET surface. The plaintext
// key is never stored; `last4` is a display-only hint derived from the plaintext at write time.
export const repositoryAiKeys = sqliteTable("repository_ai_keys", {
repoFullName: text("repo_full_name").primaryKey(),
provider: text("provider").notNull(),
ciphertext: text("ciphertext").notNull(),
iv: text("iv").notNull(),
keyVersion: integer("key_version").notNull().default(1),
model: text("model"),
last4: text("last4").notNull(),
createdBy: text("created_by"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});

export const repoSyncState = sqliteTable("repo_sync_state", {
repoFullName: text("repo_full_name").primaryKey(),
status: text("status").notNull().default("never_synced"),
Expand Down
4 changes: 4 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
declare global {

Check notice on line 1 in src/env.d.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 6 meaningful terms.

Check notice on line 1 in src/env.d.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
interface Env {
DB: D1Database;
JOBS: Queue;
Expand Down Expand Up @@ -31,6 +31,10 @@
GITTENSORY_API_TOKEN: string;
GITTENSORY_MCP_TOKEN: string;
INTERNAL_JOB_TOKEN: string;
/** AES-256-GCM master secret for maintainer BYOK provider keys (encrypt/decrypt at rest). A Worker
* secret (`wrangler secret put`), never a public var. When absent, BYOK is unavailable and the AI
* review silently falls back to free Workers AI. */
TOKEN_ENCRYPTION_SECRET?: string;
RATE_LIMIT_TRUSTED_PROXIES?: string;
RATE_LIMIT_TRUSTED_PROXY_COUNT?: string;
}
Expand Down
Loading