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
28 changes: 28 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3646,6 +3646,13 @@
"suggest",
"auto"
]
},
"autoProjectMilestoneMatchBackend": {
"type": "string",
"enum": [
"github",
"linear"
]
}
},
"required": [
Expand Down Expand Up @@ -9191,6 +9198,13 @@
"suggest",
"auto"
]
},
"autoProjectMilestoneMatchBackend": {
"type": "string",
"enum": [
"github",
"linear"
]
}
},
"required": [
Expand Down Expand Up @@ -9308,6 +9322,13 @@
"suggest",
"auto"
]
},
"autoProjectMilestoneMatchBackend": {
"type": "string",
"enum": [
"github",
"linear"
]
}
},
"required": [
Expand Down Expand Up @@ -9895,6 +9916,13 @@
"suggest",
"auto"
]
},
"autoProjectMilestoneMatchBackend": {
"type": "string",
"enum": [
"github",
"linear"
]
}
},
"required": [
Expand Down
23 changes: 23 additions & 0 deletions migrations/0111_linear_backend.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Linear adapter for project/milestone matching (#3186): lets a repo point auto-project/milestone matching
-- (#3183/#3184) at Linear instead of GitHub Projects/Milestones. Defaults to 'github' (opt-in switch, no
-- behavior change for existing repos). The Linear API key itself is NEVER stored here or in
-- repository_settings -- it lives in its own isolated table (mirroring repository_ai_keys' BYOK pattern, see
-- migrations/0027_repository_ai_keys.sql) so it is never serialized by the repository-settings GET surface,
-- and is encrypted at rest the same way (AES-256-GCM, see src/utils/crypto.ts).
ALTER TABLE repository_settings ADD COLUMN auto_project_milestone_match_backend TEXT NOT NULL DEFAULT 'github';

-- No DB-side DEFAULT CURRENT_TIMESTAMP on created_at/updated_at (unlike repository_ai_keys above): every
-- write to this table goes through Drizzle's $defaultFn(() => nowIso()) (src/db/schema.ts), which always
-- computes and supplies the ISO timestamp explicitly, so a SQLite-format fallback here would just be unused
-- surface area, not a real safeguard.
CREATE TABLE IF NOT EXISTS repository_linear_keys (
repo_full_name TEXT PRIMARY KEY,
ciphertext TEXT NOT NULL,
iv TEXT NOT NULL,
salt TEXT,
key_version INTEGER NOT NULL DEFAULT 1,
last4 TEXT NOT NULL,
created_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
81 changes: 81 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ import {
getRepositoryAiKeyStatus,
upsertRepositoryAiKey,
deleteRepositoryAiKey,
getRepositoryLinearKeyStatus,
upsertRepositoryLinearKey,
deleteRepositoryLinearKey,
getGlobalAgentFrozenState,
setGlobalAgentFrozen,
} from "../db/repositories";
Expand Down Expand Up @@ -756,6 +759,12 @@ const repositoryAiKeySchema = z
path: ["key"],
});

// Linear personal API key (#3186) -- no provider-prefix assertion (unlike the AI-key schema above): Linear's
// key format is not a stable enough public contract to hard-validate against, so only a length bound applies.
const repositoryLinearKeySchema = z.object({
key: z.string().trim().min(20).max(400),
});

// 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({
Expand Down Expand Up @@ -2516,6 +2525,41 @@ export function createApp() {
return c.json({ configured: false });
});

// Maintainer self-serve Linear API key (#3186). Write-only + live GitHub write-access scoped, mirroring the
// ai-key routes above. GET returns only {configured, last4}; the key is never returned, logged, or surfaced.
app.get("/v1/repos/:owner/:repo/linear-key", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const gate = await requireRepoWriteAccess(c, fullName);
if (gate instanceof Response) return gate;
return c.json(await getRepositoryLinearKeyStatus(c.env, fullName));
});

app.post("/v1/repos/:owner/:repo/linear-key", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const gate = await requireRepoWriteAccess(c, fullName);
if (gate instanceof Response) return gate;
const parsed = repositoryLinearKeySchema.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: "invalid_linear_key", issues: parsed.error.issues }, 400);
const createdBy = gate.identity?.kind === "session" ? gate.identity.actor : null;
try {
return c.json(await upsertRepositoryLinearKey(c.env, { repoFullName: fullName, key: parsed.data.key, 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/linear-key", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const gate = await requireRepoWriteAccess(c, fullName);
if (gate instanceof Response) return gate;
const actor = gate.identity?.kind === "session" ? gate.identity.actor : null;
await deleteRepositoryLinearKey(c.env, fullName, actor);
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 @@ -3738,6 +3782,35 @@ export function createApp() {
return c.json({ configured: false });
});

// Linear API key (#3186). 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/linear-key", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
return c.json(await getRepositoryLinearKeyStatus(c.env, fullName));
});

app.post("/v1/internal/repos/:owner/:repo/linear-key", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = repositoryLinearKeySchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_linear_key", issues: parsed.error.issues }, 400);
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
try {
const status = await upsertRepositoryLinearKey(c.env, { repoFullName: fullName, key: parsed.data.key });
return c.json(status);
} catch (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/linear-key", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
await deleteRepositoryLinearKey(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 Expand Up @@ -5054,6 +5127,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (isRepoOnboardingPackPreviewPath(path)) return true;
if (isRepoFocusManifestPath(path)) return true;
if (isRepoAiConfigPath(path)) return true;
if (isRepoLinearConfigPath(path)) return true;
if (isRepoCheckBeforeStartPath(path)) return true;
if (isRepoValidateLinkedIssuePath(path)) return true;
if (isRepoAgentAuditFeedPath(path)) return true; // route's requireRepoMaintainer enforces per-repo authority (contributors → 403)
Expand Down Expand Up @@ -5120,6 +5194,13 @@ function isRepoAiConfigPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/ai-(?:review|key)$/.test(path);
}

// #3186: without this, a session (browser) caller hits the coarse-grained "insufficient_role" 403 from this
// module's own broad path-allowlist BEFORE ever reaching the route's own requireRepoWriteAccess check --
// same shape as isRepoAiConfigPath above, just for the new Linear key route.
function isRepoLinearConfigPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/linear-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
5 changes: 3 additions & 2 deletions src/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,9 @@ export function routeClassForPath(path: string): RateLimitClass {
path.includes("/decision-pack") ||
path.includes("/miner-dashboard/refresh") ||
path.includes("/open-pr-monitor") ||
// Maintainer BYOK config: POST /ai-key runs PBKDF2 (100k iters) + an encrypted D1 upsert per request.
/\/ai-(?:key|review)$/.test(path) ||
// Maintainer BYOK config: POST /ai-key and /linear-key both run PBKDF2 (100k iters) + an encrypted D1
// upsert per request.
/\/(?:ai-(?:key|review)|linear-key)$/.test(path) ||
/^\/v1\/installations\/[^/]+\/repair\/refresh$/.test(path) ||
path.includes("/upstream/") ||
path.includes("/internal/jobs/generate-signal-snapshots") ||
Expand Down
94 changes: 94 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
repoSyncSegments,
repoSyncState,
repositoryAiKeys,
repositoryLinearKeys,
repositorySettings,
scorePreviews,
scoringModelSnapshots,
Expand Down Expand Up @@ -481,6 +482,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
gateCheckMode: "off",
reviewCheckMode: "disabled",
autoProjectMilestoneMatch: "off",
autoProjectMilestoneMatchBackend: "github",
gatePack: "gittensor",
linkedIssueGateMode: "advisory",
duplicatePrGateMode: "block",
Expand Down Expand Up @@ -552,6 +554,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
gateCheckMode: parseGateCheckMode(row.gateCheckMode),
reviewCheckMode: parseReviewCheckMode(row.reviewCheckMode),
autoProjectMilestoneMatch: parseProjectMilestoneMatchMode(row.projectMilestoneMatchMode),
autoProjectMilestoneMatchBackend: parseProjectMilestoneMatchBackend(row.autoProjectMilestoneMatchBackend),
gatePack: parseGatePack(row.gatePack),
linkedIssueGateMode: parseGateRuleMode(row.linkedIssueGateMode),
duplicatePrGateMode: parseGateRuleMode(row.duplicatePrGateMode),
Expand Down Expand Up @@ -666,6 +669,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
// only fires for callers that never cared about reviewCheckMode at all.
reviewCheckMode: settings.reviewCheckMode ?? (settings.gateCheckMode === "enabled" ? "required" : "disabled"),
autoProjectMilestoneMatch: settings.autoProjectMilestoneMatch ?? "off",
autoProjectMilestoneMatchBackend: settings.autoProjectMilestoneMatchBackend ?? "github",
gatePack: parseGatePack(settings.gatePack),
linkedIssueGateMode: settings.linkedIssueGateMode ?? "advisory",
duplicatePrGateMode: settings.duplicatePrGateMode ?? "block",
Expand Down Expand Up @@ -738,6 +742,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
gateCheckMode: resolved.gateCheckMode,
reviewCheckMode: resolved.reviewCheckMode,
projectMilestoneMatchMode: resolved.autoProjectMilestoneMatch,
autoProjectMilestoneMatchBackend: resolved.autoProjectMilestoneMatchBackend,
gatePack: resolved.gatePack,
linkedIssueGateMode: resolved.linkedIssueGateMode,
duplicatePrGateMode: resolved.duplicatePrGateMode,
Expand Down Expand Up @@ -809,6 +814,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
gateCheckMode: resolved.gateCheckMode,
reviewCheckMode: resolved.reviewCheckMode,
projectMilestoneMatchMode: resolved.autoProjectMilestoneMatch,
autoProjectMilestoneMatchBackend: resolved.autoProjectMilestoneMatchBackend,
gatePack: resolved.gatePack,
linkedIssueGateMode: resolved.linkedIssueGateMode,
duplicatePrGateMode: resolved.duplicatePrGateMode,
Expand Down Expand Up @@ -980,6 +986,90 @@ export async function getDecryptedRepositoryAiKey(env: Env, fullName: string): P
}
}

// ─── Linear personal API key (#3186) ────────────────────────────────────────────────────────────
// Same isolated-table, encrypted-at-rest shape as the BYOK provider keys above (reuses the same
// TOKEN_ENCRYPTION_SECRET + encryptSecret/decryptSecret envelope) -- never serialized by the
// repository-settings GET surface, never settable via `.gittensory.yml`, never logged in plaintext.

export type RepositoryLinearKeyStatus = { configured: true; last4: string; createdBy: string | null; updatedAt: string | null } | { configured: false };

/** Read the secret-free status of a repo's configured Linear API key (for the dashboard/API). */
export async function getRepositoryLinearKeyStatus(env: Env, fullName: string): Promise<RepositoryLinearKeyStatus> {
const db = getDb(env.DB);
const [row] = await db.select().from(repositoryLinearKeys).where(eq(repositoryLinearKeys.repoFullName, fullName)).limit(1);
if (!row) return { configured: false };
return { configured: true, last4: row.last4, createdBy: row.createdBy, updatedAt: row.updatedAt };
}

/**
* Store (or replace) a repo's Linear API 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 upsertRepositoryLinearKey(env: Env, input: { repoFullName: string; key: string; createdBy?: string | null }): Promise<RepositoryLinearKeyStatus> {
const secret = env.TOKEN_ENCRYPTION_SECRET;
if (!secret) throw new Error("missing_encryption_secret");
const trimmedKey = input.key.trim();
const existing = await getRepositoryLinearKeyStatus(env, input.repoFullName);
const { ciphertext, iv, salt, version } = await encryptSecret(trimmedKey, secret);
const last4 = trimmedKey.slice(-4);
const createdBy = input.createdBy ?? null;
const updatedAt = nowIso();
const db = getDb(env.DB);
await db
.insert(repositoryLinearKeys)
.values({ repoFullName: input.repoFullName, ciphertext, iv, salt, keyVersion: version, last4, createdBy, updatedAt })
.onConflictDoUpdate({
target: repositoryLinearKeys.repoFullName,
set: { ciphertext, iv, salt, keyVersion: version, last4, createdBy, updatedAt },
});
await recordAuditEvent(env, {
eventType: "linear_key_change",
actor: createdBy,
targetKey: input.repoFullName,
outcome: "completed",
detail: `linear key ${existing.configured ? "replace" : "set"}`,
metadata: { repoFullName: input.repoFullName, action: existing.configured ? "replace" : "set", last4 },
});
return { configured: true, last4, createdBy, updatedAt };
}

/** Remove a repo's Linear API key. Records a lifecycle audit event when a key was actually present. */
export async function deleteRepositoryLinearKey(env: Env, fullName: string, actor?: string | null): Promise<void> {
const existing = await getRepositoryLinearKeyStatus(env, fullName);
const db = getDb(env.DB);
await db.delete(repositoryLinearKeys).where(eq(repositoryLinearKeys.repoFullName, fullName));
if (existing.configured) {
await recordAuditEvent(env, {
eventType: "linear_key_change",
actor: actor ?? null,
targetKey: fullName,
outcome: "completed",
detail: "linear key delete",
metadata: { repoFullName: fullName, action: "delete", last4: existing.last4 },
});
}
}

/**
* Decrypt a repo's Linear API key for a Linear API call. Returns null when no key is configured OR the
* encryption secret is unavailable OR decryption fails -- so the caller silently degrades (no Linear match
* attempted) and a misconfiguration never blocks the PR-webhook pipeline. The plaintext key must be used
* immediately and never cached.
*/
export async function getDecryptedRepositoryLinearKey(env: Env, fullName: string): Promise<string | null> {
const secret = env.TOKEN_ENCRYPTION_SECRET;
if (!secret) return null;
const db = getDb(env.DB);
const [row] = await db.select().from(repositoryLinearKeys).where(eq(repositoryLinearKeys.repoFullName, fullName)).limit(1);
if (!row) return null;
try {
return await decryptSecret(row.ciphertext, row.iv, secret, row.salt);
} catch {
return null;
}
}

export async function upsertRepoSyncState(env: Env, state: RepoSyncStateRecord): Promise<void> {
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -6257,6 +6347,10 @@ function parseProjectMilestoneMatchMode(value: string): RepositorySettings["auto
return value === "suggest" || value === "auto" ? value : "off";
}

function parseProjectMilestoneMatchBackend(value: string): RepositorySettings["autoProjectMilestoneMatchBackend"] {
return value === "linear" ? "linear" : "github";
}

function parseGatePack(value: string | null | undefined): RepositorySettings["gatePack"] {
return value === "oss-anti-slop" ? "oss-anti-slop" : "gittensor";
}
Expand Down
16 changes: 16 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const repositorySettings = sqliteTable("repository_settings", {
gateCheckMode: text("gate_check_mode").notNull().default("off"),
reviewCheckMode: text("review_check_mode").notNull().default("disabled"),
projectMilestoneMatchMode: text("project_milestone_match_mode").notNull().default("off"),
autoProjectMilestoneMatchBackend: text("auto_project_milestone_match_backend").notNull().default("github"),
gatePack: text("gate_pack").notNull().default("gittensor"),
// Missing a linked issue is advisory-only by default -- issues aren't always available, so it only
// blocks when a repo explicitly opts in (linkedIssueGateMode: "block" or the requireLinkedIssue toggle;
Expand Down Expand Up @@ -170,6 +171,21 @@ export const repositoryAiKeys = sqliteTable("repository_ai_keys", {
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});

// Linear personal API key (#3186), encrypted at rest with AES-256-GCM -- same envelope as repositoryAiKeys
// above (see src/utils/crypto.ts), isolated in its own table for the same reason: never serialized by the
// repository-settings GET surface. The plaintext key is never stored; `last4` is a display-only hint.
export const repositoryLinearKeys = sqliteTable("repository_linear_keys", {
repoFullName: text("repo_full_name").primaryKey(),
ciphertext: text("ciphertext").notNull(),
iv: text("iv").notNull(),
salt: text("salt"),
keyVersion: integer("key_version").notNull().default(1),
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
Loading
Loading