Skip to content
Open
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
25 changes: 14 additions & 11 deletions packages/control-plane/src/db/integration-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function isValidIntegrationId(id: string): id is IntegrationId {
export class IntegrationSettingsStore {
constructor(private readonly db: D1Database) {}

async getGlobal<K extends IntegrationId>(
async getGlobal<K extends keyof IntegrationSettingsMap>(
integrationId: K
): Promise<IntegrationSettingsMap[K]["global"] | null> {
const row = await this.db
Expand All @@ -50,7 +50,7 @@ export class IntegrationSettingsStore {
return this.normalizeStoredGlobalSettings(integrationId, settings);
}

async setGlobal<K extends IntegrationId>(
async setGlobal<K extends keyof IntegrationSettingsMap>(
integrationId: K,
settings: IntegrationSettingsMap[K]["global"]
): Promise<void> {
Expand Down Expand Up @@ -87,14 +87,14 @@ export class IntegrationSettingsStore {
.run();
}

async deleteGlobal<K extends IntegrationId>(integrationId: K): Promise<void> {
async deleteGlobal<K extends keyof IntegrationSettingsMap>(integrationId: K): Promise<void> {
await this.db
.prepare("DELETE FROM integration_settings WHERE integration_id = ?")
.bind(integrationId)
.run();
}

async getRepoSettings<K extends IntegrationId>(
async getRepoSettings<K extends keyof IntegrationSettingsMap>(
integrationId: K,
repo: string
): Promise<IntegrationSettingsMap[K]["repo"] | null> {
Expand All @@ -110,7 +110,7 @@ export class IntegrationSettingsStore {
return this.normalizeStoredRepoSettings(integrationId, settings);
}

async setRepoSettings<K extends IntegrationId>(
async setRepoSettings<K extends keyof IntegrationSettingsMap>(
integrationId: K,
repo: string,
settings: IntegrationSettingsMap[K]["repo"]
Expand All @@ -130,14 +130,17 @@ export class IntegrationSettingsStore {
.run();
}

async deleteRepoSettings<K extends IntegrationId>(integrationId: K, repo: string): Promise<void> {
async deleteRepoSettings<K extends keyof IntegrationSettingsMap>(
integrationId: K,
repo: string
): Promise<void> {
await this.db
.prepare("DELETE FROM integration_repo_settings WHERE integration_id = ? AND repo = ?")
.bind(integrationId, repo.toLowerCase())
.run();
}

async listRepoSettings<K extends IntegrationId>(
async listRepoSettings<K extends keyof IntegrationSettingsMap>(
integrationId: K
): Promise<Array<{ repo: string; settings: IntegrationSettingsMap[K]["repo"] }>> {
const { results } = await this.db
Expand All @@ -154,7 +157,7 @@ export class IntegrationSettingsStore {
}));
}

async getResolvedConfig<K extends IntegrationId>(
async getResolvedConfig<K extends keyof IntegrationSettingsMap>(
integrationId: K,
repo: string
): Promise<
Expand Down Expand Up @@ -190,7 +193,7 @@ export class IntegrationSettingsStore {
>;
}

private normalizeStoredGlobalSettings<K extends IntegrationId>(
private normalizeStoredGlobalSettings<K extends keyof IntegrationSettingsMap>(
integrationId: K,
settings: IntegrationSettingsMap[K]["global"]
): IntegrationSettingsMap[K]["global"] {
Expand All @@ -201,7 +204,7 @@ export class IntegrationSettingsStore {
} as IntegrationSettingsMap[K]["global"];
}

private normalizeStoredRepoSettings<K extends IntegrationId>(
private normalizeStoredRepoSettings<K extends keyof IntegrationSettingsMap>(
integrationId: K,
settings: IntegrationSettingsMap[K]["repo"]
): IntegrationSettingsMap[K]["repo"] {
Expand All @@ -211,7 +214,7 @@ export class IntegrationSettingsStore {
}) as IntegrationSettingsMap[K]["repo"];
}

private validateAndNormalizeSettings<K extends IntegrationId>(
private validateAndNormalizeSettings<K extends keyof IntegrationSettingsMap>(
integrationId: K,
settings: IntegrationSettingsMap[K]["repo"],
level: SettingsLevel
Expand Down
117 changes: 117 additions & 0 deletions packages/control-plane/src/db/scm-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ScmGlobalConfig, ScmSettings } from "@open-inspect/shared";
import { ScmSettingsStore, ScmSettingsValidationError } from "./scm-settings";
import { IntegrationSettingsStore } from "./integration-settings";

// ScmSettingsStore is a thin wrapper over IntegrationSettingsStore pinned to the
// "scm" key. We mock the underlying store so these tests cover only the wrapper's
// responsibilities — key pinning, validation, and resolved-settings extraction —
// without touching SQL (the storage behavior is covered by integration-settings.test.ts).
const { delegate } = vi.hoisted(() => ({
delegate: {
getGlobal: vi.fn(),
setGlobal: vi.fn(),
deleteGlobal: vi.fn(),
getRepoSettings: vi.fn(),
setRepoSettings: vi.fn(),
deleteRepoSettings: vi.fn(),
listRepoSettings: vi.fn(),
getResolvedConfig: vi.fn(),
},
}));

vi.mock("./integration-settings", () => ({
IntegrationSettingsStore: vi.fn(function () {
return delegate;
}),
}));

describe("ScmSettingsStore", () => {
let store: ScmSettingsStore;

beforeEach(() => {
vi.clearAllMocks();
store = new ScmSettingsStore({} as unknown as D1Database);
});

it("delegates getGlobal to the 'scm' key", async () => {
delegate.getGlobal.mockResolvedValue({ defaults: { alwaysUseDraftMode: true } });
const result = await store.getGlobal();
expect(delegate.getGlobal).toHaveBeenCalledWith("scm");
expect(result).toEqual({ defaults: { alwaysUseDraftMode: true } });
});

it("delegates setGlobal to the 'scm' key", async () => {
await store.setGlobal({ defaults: { alwaysUseDraftMode: true } });
expect(delegate.setGlobal).toHaveBeenCalledWith("scm", {
defaults: { alwaysUseDraftMode: true },
});
});

it("delegates per-repo reads, writes, lists, and deletes to the 'scm' key", async () => {
delegate.getRepoSettings.mockResolvedValue({ alwaysUseDraftMode: false });
delegate.listRepoSettings.mockResolvedValue([
{ repo: "acme/web", settings: { alwaysUseDraftMode: true } },
]);

await store.setRepoSettings("Acme/Web", { alwaysUseDraftMode: true });
const repoSettings = await store.getRepoSettings("acme/web");
const list = await store.listRepoSettings();
await store.deleteRepoSettings("acme/web");
await store.deleteGlobal();

expect(delegate.setRepoSettings).toHaveBeenCalledWith("scm", "Acme/Web", {
alwaysUseDraftMode: true,
});
expect(delegate.getRepoSettings).toHaveBeenCalledWith("scm", "acme/web");
expect(repoSettings).toEqual({ alwaysUseDraftMode: false });
expect(list).toHaveLength(1);
expect(delegate.deleteRepoSettings).toHaveBeenCalledWith("scm", "acme/web");
expect(delegate.deleteGlobal).toHaveBeenCalledWith("scm");
});

it("rejects a non-boolean alwaysUseDraftMode and does not write", async () => {
await expect(
store.setGlobal({ defaults: { alwaysUseDraftMode: "yes" as unknown as boolean } })
).rejects.toThrow(ScmSettingsValidationError);
await expect(
store.setRepoSettings("acme/web", { alwaysUseDraftMode: 1 as unknown as boolean })
).rejects.toThrow(ScmSettingsValidationError);

expect(delegate.setGlobal).not.toHaveBeenCalled();
expect(delegate.setRepoSettings).not.toHaveBeenCalled();
});

it("rejects unknown keys and unsupported global config and does not write", async () => {
await expect(
store.setRepoSettings("acme/web", { unexpected: true } as unknown as ScmSettings)
).rejects.toThrow(ScmSettingsValidationError);
// `enabledRepos` (and any non-`defaults` global key) is not supported for scm.
await expect(
store.setGlobal({ enabledRepos: ["acme/web"] } as unknown as ScmGlobalConfig)
).rejects.toThrow(ScmSettingsValidationError);
// Arrays are not valid settings objects.
await expect(store.setGlobal([] as unknown as ScmGlobalConfig)).rejects.toThrow(
ScmSettingsValidationError
);

expect(delegate.setGlobal).not.toHaveBeenCalled();
expect(delegate.setRepoSettings).not.toHaveBeenCalled();
});

it("resolves a repo's effective settings from the underlying merged config", async () => {
delegate.getResolvedConfig.mockResolvedValue({
enabledRepos: null,
settings: { alwaysUseDraftMode: false },
});

const resolved = await store.getResolvedSettings("acme/web");

expect(delegate.getResolvedConfig).toHaveBeenCalledWith("scm", "acme/web");
expect(resolved).toEqual({ alwaysUseDraftMode: false });
});

it("constructs the underlying IntegrationSettingsStore", () => {
expect(IntegrationSettingsStore).toHaveBeenCalled();
});
});
102 changes: 102 additions & 0 deletions packages/control-plane/src/db/scm-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { ScmSettings, ScmGlobalConfig } from "@open-inspect/shared";
import { IntegrationSettingsStore } from "./integration-settings";

/**
* Storage key under which SCM settings live in the shared settings tables.
*
* SCM settings are a top-level setting, NOT an integration — they are kept out
* of the integration framework (`isValidIntegrationId`, `INTEGRATION_DEFINITIONS`).
* For storage they reuse the generic `IntegrationSettingsStore` (and its
* `integration_settings` / `integration_repo_settings` tables) under this fixed
* key, so there's no schema migration and no duplicated SQL.
*/
const SCM_SETTINGS_KEY = "scm";

export class ScmSettingsValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ScmSettingsValidationError";
}
}

const ALLOWED_SCM_SETTING_KEYS = new Set(["alwaysUseDraftMode"]);

function validateScmSettings(settings: unknown): asserts settings is ScmSettings {
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
throw new ScmSettingsValidationError("SCM settings must be an object");
}

for (const key of Object.keys(settings)) {
if (!ALLOWED_SCM_SETTING_KEYS.has(key)) {
throw new ScmSettingsValidationError(`Unknown SCM setting: ${key}`);
}
}

const { alwaysUseDraftMode } = settings as { alwaysUseDraftMode?: unknown };
if (alwaysUseDraftMode !== undefined && typeof alwaysUseDraftMode !== "boolean") {
throw new ScmSettingsValidationError("alwaysUseDraftMode must be a boolean");
}
}

/**
* Global defaults + per-repo overrides for source-control (SCM) behavior, such
* as always opening pull/merge requests as drafts. Applies to both GitHub and
* GitLab. A thin wrapper over the generic {@link IntegrationSettingsStore} that
* pins the storage key to `scm` and validates the SCM-specific shape.
*/
export class ScmSettingsStore {
private readonly store: IntegrationSettingsStore;

constructor(db: D1Database) {
this.store = new IntegrationSettingsStore(db);
}

getGlobal(): Promise<ScmGlobalConfig | null> {
return this.store.getGlobal(SCM_SETTINGS_KEY);
}

async setGlobal(config: ScmGlobalConfig): Promise<void> {
if (!config || typeof config !== "object" || Array.isArray(config)) {
throw new ScmSettingsValidationError("SCM settings must be an object");
}
// `scm` has no enable/disable-per-repo concept, so only `defaults` is
// supported at the global level. Reject anything else (e.g. `enabledRepos`)
// rather than silently persisting config that downstream resolution ignores.
for (const key of Object.keys(config)) {
if (key !== "defaults") {
throw new ScmSettingsValidationError(`Unknown SCM global setting: ${key}`);
}
}
if (config.defaults) {
validateScmSettings(config.defaults);
}
await this.store.setGlobal(SCM_SETTINGS_KEY, config);
}

deleteGlobal(): Promise<void> {
return this.store.deleteGlobal(SCM_SETTINGS_KEY);
}

getRepoSettings(repo: string): Promise<ScmSettings | null> {
return this.store.getRepoSettings(SCM_SETTINGS_KEY, repo);
}

async setRepoSettings(repo: string, settings: ScmSettings): Promise<void> {
validateScmSettings(settings);
await this.store.setRepoSettings(SCM_SETTINGS_KEY, repo, settings);
}

deleteRepoSettings(repo: string): Promise<void> {
return this.store.deleteRepoSettings(SCM_SETTINGS_KEY, repo);
}

listRepoSettings(): Promise<Array<{ repo: string; settings: ScmSettings }>> {
return this.store.listRepoSettings(SCM_SETTINGS_KEY);
}

/** Resolve a repo's effective settings: global defaults merged with the per-repo override (override wins). */
async getResolvedSettings(repo: string): Promise<ScmSettings> {
const { settings } = await this.store.getResolvedConfig(SCM_SETTINGS_KEY, repo);
return settings;
}
}
4 changes: 4 additions & 0 deletions packages/control-plane/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createRequestMetrics, instrumentD1 } from "./db/instrumented-d1";
import { createLogger } from "./logger";
import { type Route, type RequestContext, parsePattern, json, error } from "./routes/shared";
import { integrationSettingsRoutes } from "./routes/integration-settings";
import { scmSettingsRoutes } from "./routes/scm-settings";
import { modelPreferencesRoutes } from "./routes/model-preferences";
import { reposRoutes } from "./routes/repos";
import { repoImageRoutes } from "./routes/repo-images";
Expand Down Expand Up @@ -311,6 +312,9 @@ const routes: Route[] = [
// Integration settings
...integrationSettingsRoutes,

// SCM (source-control) settings
...scmSettingsRoutes,

// Repo image builds
...repoImageRoutes,

Expand Down
Loading