Skip to content
Closed
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
135 changes: 135 additions & 0 deletions migrations/0053_installation_scoped_tenant_state.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
-- Multi-tenant hosted productization (#1028): repo settings + BYOK must isolate by installation, not only by
-- repo_full_name. Rebuild both tables with an installation_id discriminator and migrate legacy rows into the
-- null-installation lane for self-host / pre-hosted compatibility.

ALTER TABLE repository_settings RENAME TO repository_settings_legacy;

CREATE TABLE repository_settings (
repo_full_name TEXT NOT NULL,
installation_id INTEGER NOT NULL DEFAULT 0,
comment_mode TEXT NOT NULL DEFAULT 'detected_contributors_only',
public_audience_mode TEXT NOT NULL DEFAULT 'oss_maintainer',
public_signal_level TEXT NOT NULL DEFAULT 'standard',
check_run_mode TEXT NOT NULL DEFAULT 'off',
check_run_detail_level TEXT NOT NULL DEFAULT 'minimal',
gate_check_mode TEXT NOT NULL DEFAULT 'off',
gate_pack TEXT NOT NULL DEFAULT 'gittensor',
linked_issue_gate_mode TEXT NOT NULL DEFAULT 'block',
duplicate_pr_gate_mode TEXT NOT NULL DEFAULT 'block',
quality_gate_mode TEXT NOT NULL DEFAULT 'advisory',
quality_gate_min_score INTEGER,
slop_gate_mode TEXT NOT NULL DEFAULT 'off',
merge_readiness_gate_mode TEXT NOT NULL DEFAULT 'off',
manifest_policy_gate_mode TEXT NOT NULL DEFAULT 'off',
first_time_contributor_grace INTEGER NOT NULL DEFAULT 0,
slop_gate_min_score INTEGER,
slop_ai_advisory INTEGER NOT NULL DEFAULT 0,
ai_review_mode TEXT NOT NULL DEFAULT 'off',
ai_review_byok INTEGER NOT NULL DEFAULT 0,
ai_review_provider TEXT,
ai_review_model TEXT,
auto_label_enabled INTEGER NOT NULL DEFAULT 1,
gittensor_label TEXT NOT NULL DEFAULT 'gittensor',
create_missing_label INTEGER NOT NULL DEFAULT 1,
public_surface TEXT NOT NULL DEFAULT 'comment_and_label',
include_maintainer_authors INTEGER NOT NULL DEFAULT 0,
require_linked_issue INTEGER NOT NULL DEFAULT 0,
backfill_enabled INTEGER NOT NULL DEFAULT 1,
private_trust_enabled INTEGER NOT NULL DEFAULT 1,
badge_enabled INTEGER NOT NULL DEFAULT 0,
command_authorization_json TEXT NOT NULL DEFAULT '{}',
autonomy_json TEXT NOT NULL DEFAULT '{}',
auto_maintain_json TEXT NOT NULL DEFAULT '{}',
agent_paused INTEGER NOT NULL DEFAULT 0,
agent_dry_run INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO repository_settings (
repo_full_name, installation_id, comment_mode, public_audience_mode, public_signal_level, check_run_mode,
check_run_detail_level, gate_check_mode, gate_pack, linked_issue_gate_mode, duplicate_pr_gate_mode,
quality_gate_mode, quality_gate_min_score, slop_gate_mode, merge_readiness_gate_mode,
manifest_policy_gate_mode, first_time_contributor_grace, slop_gate_min_score, slop_ai_advisory,
ai_review_mode, ai_review_byok, ai_review_provider, ai_review_model, auto_label_enabled, gittensor_label,
create_missing_label, public_surface, include_maintainer_authors, require_linked_issue, backfill_enabled,
private_trust_enabled, badge_enabled, command_authorization_json, autonomy_json, auto_maintain_json,
agent_paused, agent_dry_run, created_at, updated_at
)
SELECT
repo_full_name, 0, comment_mode,
COALESCE(public_audience_mode, 'oss_maintainer'),
COALESCE(public_signal_level, 'standard'),
COALESCE(check_run_mode, 'off'),
COALESCE(check_run_detail_level, 'minimal'),
COALESCE(gate_check_mode, 'off'),
COALESCE(gate_pack, 'gittensor'),
COALESCE(linked_issue_gate_mode, 'block'),
COALESCE(duplicate_pr_gate_mode, 'block'),
COALESCE(quality_gate_mode, 'advisory'),
quality_gate_min_score,
COALESCE(slop_gate_mode, 'off'),
COALESCE(merge_readiness_gate_mode, 'off'),
COALESCE(manifest_policy_gate_mode, 'off'),
COALESCE(first_time_contributor_grace, 0),
slop_gate_min_score,
COALESCE(slop_ai_advisory, 0),
COALESCE(ai_review_mode, 'off'),
COALESCE(ai_review_byok, 0),
ai_review_provider,
ai_review_model,
COALESCE(auto_label_enabled, 1),
COALESCE(gittensor_label, 'gittensor'),
COALESCE(create_missing_label, 1),
COALESCE(public_surface, 'comment_and_label'),
COALESCE(include_maintainer_authors, 0),
COALESCE(require_linked_issue, 0),
COALESCE(backfill_enabled, 1),
COALESCE(private_trust_enabled, 1),
COALESCE(badge_enabled, 0),
COALESCE(command_authorization_json, '{}'),
COALESCE(autonomy_json, '{}'),
COALESCE(auto_maintain_json, '{}'),
COALESCE(agent_paused, 0),
COALESCE(agent_dry_run, 0),
created_at,
updated_at
FROM repository_settings_legacy;

DROP TABLE repository_settings_legacy;

CREATE UNIQUE INDEX repository_settings_repo_installation_unique
ON repository_settings (repo_full_name, installation_id);
CREATE INDEX repository_settings_repo_updated_idx
ON repository_settings (repo_full_name, updated_at);

ALTER TABLE repository_ai_keys RENAME TO repository_ai_keys_legacy;

CREATE TABLE repository_ai_keys (
repo_full_name TEXT NOT NULL,
installation_id INTEGER NOT NULL DEFAULT 0,
provider TEXT NOT NULL,
ciphertext TEXT NOT NULL,
iv TEXT NOT NULL,
salt TEXT,
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
);

INSERT INTO repository_ai_keys (
repo_full_name, installation_id, provider, ciphertext, iv, salt, key_version, model, last4, created_by, created_at, updated_at
)
SELECT
repo_full_name, 0, provider, ciphertext, iv, salt, COALESCE(key_version, 1), model, last4, created_by, created_at, updated_at
FROM repository_ai_keys_legacy;

DROP TABLE repository_ai_keys_legacy;

CREATE UNIQUE INDEX repository_ai_keys_repo_installation_unique
ON repository_ai_keys (repo_full_name, installation_id);
CREATE INDEX repository_ai_keys_repo_updated_idx
ON repository_ai_keys (repo_full_name, updated_at);
138 changes: 84 additions & 54 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export async function markInstallationDeleted(env: Env, installationId: number):
await db.update(installations).set({ suspendedAt: nowIso(), updatedAt: nowIso() }).where(eq(installations.id, installationId));
await db
.update(repositories)
.set({ isInstalled: false, installationId: null, updatedAt: nowIso() })
.set({ isInstalled: false, installationId: sql`null`, updatedAt: nowIso() })
.where(eq(repositories.installationId, installationId));
}

Expand All @@ -239,7 +239,7 @@ export async function markRepositoriesRemovedFromInstallation(env: Env, installa
const db = getDb(env.DB);
await db
.update(repositories)
.set({ isInstalled: false, installationId: null, updatedAt: nowIso() })
.set({ isInstalled: false, installationId: sql`null`, updatedAt: nowIso() })
.where(and(eq(repositories.installationId, installationId), inArray(repositories.fullName, names)));
}

Expand Down Expand Up @@ -397,10 +397,17 @@ export async function listRepositories(env: Env): Promise<RepositoryRecord[]> {

export async function getRepositorySettings(env: Env, fullName: string): Promise<RepositorySettings> {
const db = getDb(env.DB);
const [row] = await db.select().from(repositorySettings).where(eq(repositorySettings.repoFullName, fullName)).limit(1);
if (!row) {
const installationId = await resolveScopedInstallationId(env, fullName);
const [row] = await db
.select()
.from(repositorySettings)
.where(and(eq(repositorySettings.repoFullName, fullName), eq(repositorySettings.installationId, installationId)))
.limit(1);
const effectiveRow = row;
if (!effectiveRow) {
return {
repoFullName: fullName,
installationId,
commentMode: "detected_contributors_only",
publicAudienceMode: "oss_maintainer",
publicSignalLevel: "standard",
Expand Down Expand Up @@ -439,50 +446,53 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
};
}
return {
repoFullName: row.repoFullName,
commentMode: parseCommentMode(row.commentMode),
publicAudienceMode: parsePublicAudienceMode(row.publicAudienceMode),
publicSignalLevel: row.publicSignalLevel === "minimal" ? "minimal" : "standard",
checkRunMode: parseCheckRunMode(row.checkRunMode),
checkRunDetailLevel: parseCheckRunDetailLevel(row.checkRunDetailLevel),
gateCheckMode: parseGateCheckMode(row.gateCheckMode),
gatePack: parseGatePack(row.gatePack),
linkedIssueGateMode: parseGateRuleMode(row.linkedIssueGateMode),
duplicatePrGateMode: parseGateRuleMode(row.duplicatePrGateMode),
qualityGateMode: parseGateRuleMode(row.qualityGateMode),
qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore),
slopGateMode: parseGateRuleMode(row.slopGateMode),
mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode),
manifestPolicyGateMode: parseGateRuleMode(row.manifestPolicyGateMode),
firstTimeContributorGrace: row.firstTimeContributorGrace,
slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore),
slopAiAdvisory: row.slopAiAdvisory,
aiReviewMode: parseGateRuleMode(row.aiReviewMode),
aiReviewByok: row.aiReviewByok,
aiReviewProvider: normalizeAiReviewProvider(row.aiReviewProvider),
aiReviewModel: row.aiReviewModel ?? null,
autoLabelEnabled: row.autoLabelEnabled,
gittensorLabel: row.gittensorLabel,
createMissingLabel: row.createMissingLabel,
publicSurface: parsePublicSurface(row.publicSurface),
includeMaintainerAuthors: row.includeMaintainerAuthors,
requireLinkedIssue: row.requireLinkedIssue,
backfillEnabled: row.backfillEnabled,
privateTrustEnabled: row.privateTrustEnabled,
badgeEnabled: row.badgeEnabled,
agentPaused: row.agentPaused,
agentDryRun: row.agentDryRun,
commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson),
autonomy: parseAutonomyPolicy(row.autonomyJson),
autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repoFullName: effectiveRow.repoFullName,
installationId: effectiveRow.installationId ?? installationId,
commentMode: parseCommentMode(effectiveRow.commentMode),
publicAudienceMode: parsePublicAudienceMode(effectiveRow.publicAudienceMode),
publicSignalLevel: effectiveRow.publicSignalLevel === "minimal" ? "minimal" : "standard",
checkRunMode: parseCheckRunMode(effectiveRow.checkRunMode),
checkRunDetailLevel: parseCheckRunDetailLevel(effectiveRow.checkRunDetailLevel),
gateCheckMode: parseGateCheckMode(effectiveRow.gateCheckMode),
gatePack: parseGatePack(effectiveRow.gatePack),
linkedIssueGateMode: parseGateRuleMode(effectiveRow.linkedIssueGateMode),
duplicatePrGateMode: parseGateRuleMode(effectiveRow.duplicatePrGateMode),
qualityGateMode: parseGateRuleMode(effectiveRow.qualityGateMode),
qualityGateMinScore: normalizeQualityGateMinScore(effectiveRow.qualityGateMinScore),
slopGateMode: parseGateRuleMode(effectiveRow.slopGateMode),
mergeReadinessGateMode: parseGateRuleMode(effectiveRow.mergeReadinessGateMode),
manifestPolicyGateMode: parseGateRuleMode(effectiveRow.manifestPolicyGateMode),
firstTimeContributorGrace: effectiveRow.firstTimeContributorGrace,
slopGateMinScore: normalizeQualityGateMinScore(effectiveRow.slopGateMinScore),
slopAiAdvisory: effectiveRow.slopAiAdvisory,
aiReviewMode: parseGateRuleMode(effectiveRow.aiReviewMode),
aiReviewByok: effectiveRow.aiReviewByok,
aiReviewProvider: normalizeAiReviewProvider(effectiveRow.aiReviewProvider),
aiReviewModel: effectiveRow.aiReviewModel ?? null,
autoLabelEnabled: effectiveRow.autoLabelEnabled,
gittensorLabel: effectiveRow.gittensorLabel,
createMissingLabel: effectiveRow.createMissingLabel,
publicSurface: parsePublicSurface(effectiveRow.publicSurface),
includeMaintainerAuthors: effectiveRow.includeMaintainerAuthors,
requireLinkedIssue: effectiveRow.requireLinkedIssue,
backfillEnabled: effectiveRow.backfillEnabled,
privateTrustEnabled: effectiveRow.privateTrustEnabled,
badgeEnabled: effectiveRow.badgeEnabled,
agentPaused: effectiveRow.agentPaused,
agentDryRun: effectiveRow.agentDryRun,
commandAuthorization: parseCommandAuthorizationPolicy(effectiveRow.commandAuthorizationJson),
autonomy: parseAutonomyPolicy(effectiveRow.autonomyJson),
autoMaintain: parseAutoMaintainPolicy(effectiveRow.autoMaintainJson),
createdAt: effectiveRow.createdAt,
updatedAt: effectiveRow.updatedAt,
};
}

export async function upsertRepositorySettings(env: Env, settings: Partial<RepositorySettings> & { repoFullName: string }): Promise<RepositorySettings> {
const installationId = settings.installationId ?? (await resolveScopedInstallationId(env, settings.repoFullName));
const resolved: RepositorySettings = {
repoFullName: settings.repoFullName,
installationId,
commentMode: settings.commentMode ?? "detected_contributors_only",
publicAudienceMode: settings.publicAudienceMode ?? "oss_maintainer",
publicSignalLevel: settings.publicSignalLevel ?? "standard",
Expand Down Expand Up @@ -524,6 +534,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
.insert(repositorySettings)
.values({
repoFullName: resolved.repoFullName,
installationId: resolved.installationId ?? 0,
commentMode: resolved.commentMode,
publicAudienceMode: resolved.publicAudienceMode,
publicSignalLevel: resolved.publicSignalLevel,
Expand Down Expand Up @@ -562,7 +573,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
updatedAt: nowIso(),
})
.onConflictDoUpdate({
target: repositorySettings.repoFullName,
target: [repositorySettings.repoFullName, repositorySettings.installationId],
set: {
commentMode: resolved.commentMode,
publicAudienceMode: resolved.publicAudienceMode,
Expand Down Expand Up @@ -626,9 +637,15 @@ function normalizeAiKeyProvider(value: string): AiKeyProvider {
/** 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, createdBy: row.createdBy, updatedAt: row.updatedAt };
const installationId = await resolveScopedInstallationId(env, fullName);
const [row] = await db
.select()
.from(repositoryAiKeys)
.where(and(eq(repositoryAiKeys.repoFullName, fullName), eq(repositoryAiKeys.installationId, installationId)))
.limit(1);
const effectiveRow = row;
if (!effectiveRow) return { configured: false };
return { configured: true, provider: normalizeAiKeyProvider(effectiveRow.provider), last4: effectiveRow.last4, model: effectiveRow.model ?? null, createdBy: effectiveRow.createdBy, updatedAt: effectiveRow.updatedAt };
}

/**
Expand All @@ -638,11 +655,12 @@ export async function getRepositoryAiKeyStatus(env: Env, fullName: string): Prom
*/
export async function upsertRepositoryAiKey(
env: Env,
input: { repoFullName: string; provider: AiKeyProvider; key: string; model?: string | null; createdBy?: string | null },
input: { repoFullName: string; provider: AiKeyProvider; key: string; model?: string | null; createdBy?: string | null; installationId?: number | null | undefined },
): Promise<RepositoryAiKeyStatus> {
const secret = env.TOKEN_ENCRYPTION_SECRET;
if (!secret) throw new Error("missing_encryption_secret");
const trimmedKey = input.key.trim();
const installationId = input.installationId ?? (await resolveScopedInstallationId(env, input.repoFullName));
const existing = await getRepositoryAiKeyStatus(env, input.repoFullName);
const { ciphertext, iv, salt, version } = await encryptSecret(trimmedKey, secret);
const last4 = trimmedKey.slice(-4);
Expand All @@ -652,9 +670,9 @@ export async function upsertRepositoryAiKey(
const db = getDb(env.DB);
await db
.insert(repositoryAiKeys)
.values({ repoFullName: input.repoFullName, provider: input.provider, ciphertext, iv, salt, keyVersion: version, model, last4, createdBy, updatedAt })
.values({ repoFullName: input.repoFullName, installationId: installationId ?? 0, provider: input.provider, ciphertext, iv, salt, keyVersion: version, model, last4, createdBy, updatedAt })
.onConflictDoUpdate({
target: repositoryAiKeys.repoFullName,
target: [repositoryAiKeys.repoFullName, repositoryAiKeys.installationId],
set: { provider: input.provider, ciphertext, iv, salt, keyVersion: version, model, last4, createdBy, updatedAt },
});
await recordAiKeyChange(env, { repoFullName: input.repoFullName, action: existing.configured ? "replace" : "set", provider: input.provider, last4, actor: createdBy });
Expand All @@ -664,8 +682,9 @@ export async function upsertRepositoryAiKey(
/** Remove a repo's BYOK key. Records a lifecycle audit event when a key was actually present. */
export async function deleteRepositoryAiKey(env: Env, fullName: string, actor?: string | null): Promise<void> {
const existing = await getRepositoryAiKeyStatus(env, fullName);
const installationId = await resolveScopedInstallationId(env, fullName);
const db = getDb(env.DB);
await db.delete(repositoryAiKeys).where(eq(repositoryAiKeys.repoFullName, fullName));
await db.delete(repositoryAiKeys).where(and(eq(repositoryAiKeys.repoFullName, fullName), eq(repositoryAiKeys.installationId, installationId)));
if (existing.configured) {
await recordAiKeyChange(env, { repoFullName: fullName, action: "delete", provider: existing.provider, last4: existing.last4, actor: actor ?? null });
}
Expand Down Expand Up @@ -701,16 +720,27 @@ export async function getDecryptedRepositoryAiKey(env: Env, fullName: string): P
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;
const installationId = await resolveScopedInstallationId(env, fullName);
const [row] = await db
.select()
.from(repositoryAiKeys)
.where(and(eq(repositoryAiKeys.repoFullName, fullName), eq(repositoryAiKeys.installationId, installationId)))
.limit(1);
const effectiveRow = row;
if (!effectiveRow) return null;
try {
const key = await decryptSecret(row.ciphertext, row.iv, secret, row.salt);
return { provider: normalizeAiKeyProvider(row.provider), key, model: row.model ?? null };
const key = await decryptSecret(effectiveRow.ciphertext, effectiveRow.iv, secret, effectiveRow.salt);
return { provider: normalizeAiKeyProvider(effectiveRow.provider), key, model: effectiveRow.model ?? null };
} catch {
return null;
}
}

async function resolveScopedInstallationId(env: Env, repoFullName: string): Promise<number> {
const repo = await getRepository(env, repoFullName);
return repo?.installationId ?? 0;
}

export async function upsertRepoSyncState(env: Env, state: RepoSyncStateRecord): Promise<void> {
const db = getDb(env.DB);
await db
Expand Down
Loading
Loading