diff --git a/packages/control-plane/src/db/integration-settings.ts b/packages/control-plane/src/db/integration-settings.ts index 6ebcc4eea..e4c575482 100644 --- a/packages/control-plane/src/db/integration-settings.ts +++ b/packages/control-plane/src/db/integration-settings.ts @@ -37,7 +37,7 @@ export function isValidIntegrationId(id: string): id is IntegrationId { export class IntegrationSettingsStore { constructor(private readonly db: D1Database) {} - async getGlobal( + async getGlobal( integrationId: K ): Promise { const row = await this.db @@ -50,7 +50,7 @@ export class IntegrationSettingsStore { return this.normalizeStoredGlobalSettings(integrationId, settings); } - async setGlobal( + async setGlobal( integrationId: K, settings: IntegrationSettingsMap[K]["global"] ): Promise { @@ -87,14 +87,14 @@ export class IntegrationSettingsStore { .run(); } - async deleteGlobal(integrationId: K): Promise { + async deleteGlobal(integrationId: K): Promise { await this.db .prepare("DELETE FROM integration_settings WHERE integration_id = ?") .bind(integrationId) .run(); } - async getRepoSettings( + async getRepoSettings( integrationId: K, repo: string ): Promise { @@ -110,7 +110,7 @@ export class IntegrationSettingsStore { return this.normalizeStoredRepoSettings(integrationId, settings); } - async setRepoSettings( + async setRepoSettings( integrationId: K, repo: string, settings: IntegrationSettingsMap[K]["repo"] @@ -130,14 +130,17 @@ export class IntegrationSettingsStore { .run(); } - async deleteRepoSettings(integrationId: K, repo: string): Promise { + async deleteRepoSettings( + integrationId: K, + repo: string + ): Promise { await this.db .prepare("DELETE FROM integration_repo_settings WHERE integration_id = ? AND repo = ?") .bind(integrationId, repo.toLowerCase()) .run(); } - async listRepoSettings( + async listRepoSettings( integrationId: K ): Promise> { const { results } = await this.db @@ -154,7 +157,7 @@ export class IntegrationSettingsStore { })); } - async getResolvedConfig( + async getResolvedConfig( integrationId: K, repo: string ): Promise< @@ -190,7 +193,7 @@ export class IntegrationSettingsStore { >; } - private normalizeStoredGlobalSettings( + private normalizeStoredGlobalSettings( integrationId: K, settings: IntegrationSettingsMap[K]["global"] ): IntegrationSettingsMap[K]["global"] { @@ -201,7 +204,7 @@ export class IntegrationSettingsStore { } as IntegrationSettingsMap[K]["global"]; } - private normalizeStoredRepoSettings( + private normalizeStoredRepoSettings( integrationId: K, settings: IntegrationSettingsMap[K]["repo"] ): IntegrationSettingsMap[K]["repo"] { @@ -211,7 +214,7 @@ export class IntegrationSettingsStore { }) as IntegrationSettingsMap[K]["repo"]; } - private validateAndNormalizeSettings( + private validateAndNormalizeSettings( integrationId: K, settings: IntegrationSettingsMap[K]["repo"], level: SettingsLevel diff --git a/packages/control-plane/src/db/scm-settings.test.ts b/packages/control-plane/src/db/scm-settings.test.ts new file mode 100644 index 000000000..75c1c5ac5 --- /dev/null +++ b/packages/control-plane/src/db/scm-settings.test.ts @@ -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(); + }); +}); diff --git a/packages/control-plane/src/db/scm-settings.ts b/packages/control-plane/src/db/scm-settings.ts new file mode 100644 index 000000000..5f77fe5fb --- /dev/null +++ b/packages/control-plane/src/db/scm-settings.ts @@ -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 { + return this.store.getGlobal(SCM_SETTINGS_KEY); + } + + async setGlobal(config: ScmGlobalConfig): Promise { + 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 { + return this.store.deleteGlobal(SCM_SETTINGS_KEY); + } + + getRepoSettings(repo: string): Promise { + return this.store.getRepoSettings(SCM_SETTINGS_KEY, repo); + } + + async setRepoSettings(repo: string, settings: ScmSettings): Promise { + validateScmSettings(settings); + await this.store.setRepoSettings(SCM_SETTINGS_KEY, repo, settings); + } + + deleteRepoSettings(repo: string): Promise { + return this.store.deleteRepoSettings(SCM_SETTINGS_KEY, repo); + } + + listRepoSettings(): Promise> { + 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 { + const { settings } = await this.store.getResolvedConfig(SCM_SETTINGS_KEY, repo); + return settings; + } +} diff --git a/packages/control-plane/src/router.ts b/packages/control-plane/src/router.ts index 42d0a89a1..6543afadb 100644 --- a/packages/control-plane/src/router.ts +++ b/packages/control-plane/src/router.ts @@ -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"; @@ -311,6 +312,9 @@ const routes: Route[] = [ // Integration settings ...integrationSettingsRoutes, + // SCM (source-control) settings + ...scmSettingsRoutes, + // Repo image builds ...repoImageRoutes, diff --git a/packages/control-plane/src/routes/scm-settings.ts b/packages/control-plane/src/routes/scm-settings.ts new file mode 100644 index 000000000..89c9526e7 --- /dev/null +++ b/packages/control-plane/src/routes/scm-settings.ts @@ -0,0 +1,220 @@ +/** + * SCM (source-control) settings routes. + * + * SCM settings are a top-level setting, separate from the integration-settings + * framework. They control how sessions open pull/merge requests (e.g. always as + * drafts) for both GitHub and GitLab. + */ + +import type { ScmGlobalConfig, ScmSettings } from "@open-inspect/shared"; +import { ScmSettingsStore, ScmSettingsValidationError } from "../db/scm-settings"; +import type { Env } from "../types"; +import { createLogger } from "../logger"; +import { + type Route, + type RequestContext, + parsePattern, + json, + error, + parseJsonBody, + extractRepoParams, +} from "./shared"; + +const logger = createLogger("router:scm-settings"); + +async function handleGetGlobal( + _request: Request, + env: Env, + _match: RegExpMatchArray, + _ctx: RequestContext +): Promise { + if (!env.DB) { + return json({ settings: null }); + } + const store = new ScmSettingsStore(env.DB); + const settings = await store.getGlobal(); + return json({ settings }); +} + +async function handleSetGlobal( + request: Request, + env: Env, + _match: RegExpMatchArray, + ctx: RequestContext +): Promise { + if (!env.DB) { + return error("SCM settings storage is not configured", 503); + } + + const body = await parseJsonBody<{ settings?: ScmGlobalConfig }>(request); + if (body instanceof Response) return body; + + if (!body?.settings || typeof body.settings !== "object" || Array.isArray(body.settings)) { + return error("Request body must include settings object", 400); + } + + const store = new ScmSettingsStore(env.DB); + + try { + await store.setGlobal(body.settings); + logger.info("scm_settings.updated", { + event: "scm_settings.updated", + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return json({ status: "updated" }); + } catch (e) { + if (e instanceof ScmSettingsValidationError) { + return error(e.message, 400); + } + logger.error("Failed to update SCM settings", { + error: e instanceof Error ? e.message : String(e), + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error("SCM settings storage unavailable", 503); + } +} + +async function handleDeleteGlobal( + _request: Request, + env: Env, + _match: RegExpMatchArray, + ctx: RequestContext +): Promise { + if (!env.DB) { + return error("SCM settings storage is not configured", 503); + } + + const store = new ScmSettingsStore(env.DB); + + try { + await store.deleteGlobal(); + logger.info("scm_settings.deleted", { + event: "scm_settings.deleted", + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return json({ status: "deleted" }); + } catch (e) { + logger.error("Failed to delete SCM settings", { + error: e instanceof Error ? e.message : String(e), + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error("SCM settings storage unavailable", 503); + } +} + +async function handleListRepoSettings( + _request: Request, + env: Env, + _match: RegExpMatchArray, + _ctx: RequestContext +): Promise { + if (!env.DB) { + return json({ repos: [] }); + } + const store = new ScmSettingsStore(env.DB); + const repos = await store.listRepoSettings(); + return json({ repos }); +} + +async function handleSetRepoSettings( + request: Request, + env: Env, + match: RegExpMatchArray, + ctx: RequestContext +): Promise { + const params = extractRepoParams(match); + if (params instanceof Response) return params; + const { owner, name } = params; + const repo = `${owner}/${name}`; + + if (!env.DB) { + return error("SCM settings storage is not configured", 503); + } + + const body = await parseJsonBody<{ settings?: ScmSettings }>(request); + if (body instanceof Response) return body; + + if (!body?.settings || typeof body.settings !== "object" || Array.isArray(body.settings)) { + return error("Request body must include settings object", 400); + } + + const store = new ScmSettingsStore(env.DB); + + try { + await store.setRepoSettings(repo, body.settings); + logger.info("scm_repo_settings.updated", { + event: "scm_repo_settings.updated", + repo, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return json({ status: "updated", repo }); + } catch (e) { + if (e instanceof ScmSettingsValidationError) { + return error(e.message, 400); + } + logger.error("Failed to update SCM repo settings", { + error: e instanceof Error ? e.message : String(e), + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error("SCM settings storage unavailable", 503); + } +} + +async function handleDeleteRepoSettings( + _request: Request, + env: Env, + match: RegExpMatchArray, + ctx: RequestContext +): Promise { + const params = extractRepoParams(match); + if (params instanceof Response) return params; + const { owner, name } = params; + const repo = `${owner}/${name}`; + + if (!env.DB) { + return error("SCM settings storage is not configured", 503); + } + + const store = new ScmSettingsStore(env.DB); + + try { + await store.deleteRepoSettings(repo); + logger.info("scm_repo_settings.deleted", { + event: "scm_repo_settings.deleted", + repo, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return json({ status: "deleted", repo }); + } catch (e) { + logger.error("Failed to delete SCM repo settings", { + error: e instanceof Error ? e.message : String(e), + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error("SCM settings storage unavailable", 503); + } +} + +export const scmSettingsRoutes: Route[] = [ + { method: "GET", pattern: parsePattern("/scm-settings"), handler: handleGetGlobal }, + { method: "PUT", pattern: parsePattern("/scm-settings"), handler: handleSetGlobal }, + { method: "DELETE", pattern: parsePattern("/scm-settings"), handler: handleDeleteGlobal }, + { method: "GET", pattern: parsePattern("/scm-settings/repos"), handler: handleListRepoSettings }, + { + method: "PUT", + pattern: parsePattern("/scm-settings/repos/:owner/:name"), + handler: handleSetRepoSettings, + }, + { + method: "DELETE", + pattern: parsePattern("/scm-settings/repos/:owner/:name"), + handler: handleDeleteRepoSettings, + }, +]; diff --git a/packages/control-plane/src/routes/session-runtime-proxy.test.ts b/packages/control-plane/src/routes/session-runtime-proxy.test.ts index 61568e3c9..64cebd7bf 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.test.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.test.ts @@ -136,6 +136,51 @@ describe("session runtime proxy routes", () => { expect(fetch).not.toHaveBeenCalled(); }); + it("forwards the draft flag through the create-PR contract", async () => { + const requests: Request[] = []; + const fetch = vi.fn(async (request: Request) => { + requests.push(request); + return Response.json({ prNumber: 1, prUrl: "https://example/pr/1", state: "draft" }); + }); + const { handler, match } = getHandler("POST", "/sessions/session-1/pr"); + + const response = await handler( + new Request("https://test.local/sessions/session-1/pr", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "T", body: "B", draft: true }), + }), + createEnv(fetch), + match, + createCtx() + ); + + expect(response.status).toBe(200); + expect(fetch).toHaveBeenCalledOnce(); + expect(new URL(requests[0].url).pathname).toBe(SessionInternalPaths.createPr); + await expect(requests[0].json()).resolves.toMatchObject({ title: "T", body: "B", draft: true }); + }); + + it("rejects a non-boolean draft without forwarding to the runtime", async () => { + const fetch = vi.fn(async () => Response.json({ status: "ok" })); + const { handler, match } = getHandler("POST", "/sessions/session-1/pr"); + + const response = await handler( + new Request("https://test.local/sessions/session-1/pr", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "T", body: "B", draft: "yes" }), + }), + createEnv(fetch), + match, + createCtx() + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "draft must be a boolean" }); + expect(fetch).not.toHaveBeenCalled(); + }); + it("rejects malformed create-PR JSON without forwarding to the runtime", async () => { const fetch = vi.fn(async () => Response.json({ status: "ok" })); const { handler, match } = getHandler("POST", "/sessions/session-1/pr"); diff --git a/packages/control-plane/src/routes/session-runtime-proxy.ts b/packages/control-plane/src/routes/session-runtime-proxy.ts index 919b0c119..82dde368b 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.ts @@ -94,6 +94,10 @@ async function handleCreatePR( return error("headBranch must be a string"); } + if (body.draft !== undefined && typeof body.draft !== "boolean") { + return error("draft must be a boolean"); + } + return ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.createPr, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -102,6 +106,7 @@ async function handleCreatePR( body: body.body, baseBranch: body.baseBranch, headBranch: body.headBranch, + draft: body.draft, }), }); } diff --git a/packages/control-plane/src/session/durable-object.ts b/packages/control-plane/src/session/durable-object.ts index a7274217a..694a3f797 100644 --- a/packages/control-plane/src/session/durable-object.ts +++ b/packages/control-plane/src/session/durable-object.ts @@ -41,6 +41,7 @@ import { import { RepoImageStore } from "../db/repo-images"; import { McpServerStore } from "../db/mcp-servers"; import { IntegrationSettingsStore, resolveSlackSettings } from "../db/integration-settings"; +import { ScmSettingsStore } from "../db/scm-settings"; import { SessionIndexStore } from "../db/session-index"; import { DEFAULT_EXECUTION_TIMEOUT_MS } from "../sandbox/lifecycle/decisions"; import { @@ -496,6 +497,7 @@ export class SessionDO extends DurableObject { }); }, appName: resolveAppName(this.env), + resolveAlwaysDraftDefault: () => this.resolveAlwaysDraftDefault(), }); return pullRequestService.createPullRequest(input); @@ -516,6 +518,29 @@ export class SessionDO extends DurableObject { return this._participantsHandler; } + /** + * Resolves the "always use draft mode" SCM setting (global default merged + * with the per-repo override) for this session's repository. Returns false + * when D1 is unavailable so PR creation never blocks on settings. + */ + private async resolveAlwaysDraftDefault(): Promise { + if (!this.env.DB) return false; + const session = this.getSession(); + if (!session) return false; + try { + const scmSettingsStore = new ScmSettingsStore(this.env.DB); + const settings = await scmSettingsStore.getResolvedSettings( + `${session.repo_owner}/${session.repo_name}` + ); + return settings.alwaysUseDraftMode === true; + } catch (error) { + this.log.error("Failed to resolve always-draft setting", { + error: error instanceof Error ? error : String(error), + }); + return false; + } + } + private get alarmHandler(): AlarmHandler { if (!this._alarmHandler) { this._alarmHandler = createAlarmHandler({ diff --git a/packages/control-plane/src/session/http/handlers/pull-request.handler.ts b/packages/control-plane/src/session/http/handlers/pull-request.handler.ts index a00df9a1e..5ce9ee55e 100644 --- a/packages/control-plane/src/session/http/handlers/pull-request.handler.ts +++ b/packages/control-plane/src/session/http/handlers/pull-request.handler.ts @@ -8,6 +8,7 @@ const createPrRequestSchema = z.object({ body: z.string(), baseBranch: z.string().optional(), headBranch: z.string().optional(), + draft: z.boolean().optional(), }); type CreatePrRequest = z.infer; diff --git a/packages/control-plane/src/session/pull-request-service.test.ts b/packages/control-plane/src/session/pull-request-service.test.ts index a2778c9a9..cd5202187 100644 --- a/packages/control-plane/src/session/pull-request-service.test.ts +++ b/packages/control-plane/src/session/pull-request-service.test.ts @@ -95,7 +95,7 @@ function createInput(overrides: Partial = {}): CreatePul }; } -function createTestHarness() { +function createTestHarness(options: { alwaysDraftDefault?: boolean } = {}) { const log = createMockLogger(); const provider = createMockProvider(); const artifacts: ArtifactRow[] = []; @@ -130,6 +130,7 @@ function createTestHarness() { broadcastSessionBranch: vi.fn(), broadcastArtifactCreated: vi.fn(), appName: "Open-Inspect", + resolveAlwaysDraftDefault: vi.fn(async () => options.alwaysDraftDefault ?? false), }; const service = new SessionPullRequestService(deps); @@ -258,6 +259,46 @@ describe("SessionPullRequestService", () => { expect(harness.deps.broadcastSessionBranch).toHaveBeenCalledWith("feature/test"); }); + it("passes draft=false to the provider by default", async () => { + await harness.service.createPullRequest(createInput()); + + expect(harness.provider.createPullRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ draft: false }) + ); + }); + + it("forwards an explicit draft flag from the request", async () => { + await harness.service.createPullRequest(createInput({ draft: true })); + + expect(harness.provider.createPullRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ draft: true }) + ); + }); + + it("falls back to the always-draft default when draft is unspecified", async () => { + harness = createTestHarness({ alwaysDraftDefault: true }); + + await harness.service.createPullRequest(createInput()); + + expect(harness.provider.createPullRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ draft: true }) + ); + }); + + it("forces draft when always-draft is enabled, even if the request sets draft=false", async () => { + harness = createTestHarness({ alwaysDraftDefault: true }); + + await harness.service.createPullRequest(createInput({ draft: false })); + + expect(harness.provider.createPullRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ draft: true }) + ); + }); + it("creates PR with OAuth token and stores PR artifact", async () => { const result = await harness.service.createPullRequest( createInput({ promptingAuth: { authType: "oauth", token: "user-token" } }) diff --git a/packages/control-plane/src/session/pull-request-service.ts b/packages/control-plane/src/session/pull-request-service.ts index 6b273a8ea..658a82dcc 100644 --- a/packages/control-plane/src/session/pull-request-service.ts +++ b/packages/control-plane/src/session/pull-request-service.ts @@ -21,6 +21,11 @@ export interface CreatePullRequestInput { promptingUserId: string; promptingAuth: SourceControlAuthContext | null; sessionUrl: string; + /** + * Whether to open the PR in draft mode. When configured, the SCM setting + * "always use draft mode" overrides. + */ + draft?: boolean; } export type CreatePullRequestResult = @@ -63,6 +68,11 @@ export interface PullRequestServiceDeps { broadcastArtifactCreated: (artifact: SessionArtifact) => void; /** Display name used in the PR body footer (e.g. "Created with [name](url)"). */ appName: string; + /** + * Resolves the "always use draft mode" default (global default merged with + * repo override) for this session's repository. + */ + resolveAlwaysDraftDefault: () => Promise; } /** @@ -188,12 +198,20 @@ export class SessionPullRequestService { const fullBody = input.body + `\n\n---\n*Created with [${this.deps.appName}](${input.sessionUrl})*`; + // The "always use draft mode" SCM setting is a hard policy: when enabled + // for this repo it forces every session-created PR to be a draft, + // regardless of the tool's `draft` argument. When disabled, an explicit + // `draft` from the request decides (defaulting to false). + const alwaysDraft = await this.deps.resolveAlwaysDraftDefault(); + const draft = alwaysDraft || (input.draft ?? false); + const prResult = await this.deps.sourceControlProvider.createPullRequest(prAuth, { repository: repoInfo, title: input.title, body: fullBody, sourceBranch: sanitizedHeadBranch, targetBranch: baseBranch, + draft, }); const artifactId = this.deps.generateId(); diff --git a/packages/control-plane/src/source-control/providers/github-provider.test.ts b/packages/control-plane/src/source-control/providers/github-provider.test.ts index 8b95a81d9..238a3dba9 100644 --- a/packages/control-plane/src/source-control/providers/github-provider.test.ts +++ b/packages/control-plane/src/source-control/providers/github-provider.test.ts @@ -12,6 +12,7 @@ vi.mock("../../auth/github-app", () => ({ })); import { + fetchWithTimeout, getCachedInstallationTokenWithExpiry, getInstallationRepository, listInstallationRepositories, @@ -20,6 +21,26 @@ import { const mockGetInstallationRepository = vi.mocked(getInstallationRepository); const mockListInstallationRepositories = vi.mocked(listInstallationRepositories); const mockGetCachedInstallationTokenWithExpiry = vi.mocked(getCachedInstallationTokenWithExpiry); +const mockFetchWithTimeout = vi.mocked(fetchWithTimeout); + +function makeResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === "string" ? body : JSON.stringify(body)), + } as unknown as Response; +} + +const fakeAuth = { authType: "app" as const, token: "ghs_test" }; +const fakeRepository = { + owner: "acme", + name: "web", + fullName: "acme/web", + defaultBranch: "main", + isPrivate: true, + providerRepoId: 1, +}; const fakeAppConfig = { appId: "123", @@ -345,4 +366,76 @@ describe("GitHubSourceControlProvider", () => { expect((err as SourceControlProviderError).httpStatus).toBe(500); }); }); + + describe("createPullRequest", () => { + const prResponseBody = { + number: 7, + html_url: "https://github.com/acme/web/pull/7", + url: "https://api.github.com/repos/acme/web/pulls/7", + state: "open", + draft: false, + merged: false, + head: { ref: "feature" }, + base: { ref: "main" }, + }; + + it("creates a non-draft PR by default and does not send the draft flag", async () => { + mockFetchWithTimeout.mockResolvedValueOnce(makeResponse(prResponseBody)); + + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + const result = await provider.createPullRequest(fakeAuth, { + repository: fakeRepository, + title: "Add feature", + body: "Body", + sourceBranch: "feature", + targetBranch: "main", + }); + + expect(result.id).toBe(7); + expect(result.state).toBe("open"); + expect(mockFetchWithTimeout).toHaveBeenCalledTimes(1); + const sentBody = JSON.parse(mockFetchWithTimeout.mock.calls[0][1]?.body as string); + expect(sentBody.draft).toBeUndefined(); + }); + + it("forwards the draft flag when draft is requested", async () => { + mockFetchWithTimeout.mockResolvedValueOnce(makeResponse({ ...prResponseBody, draft: true })); + + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + const result = await provider.createPullRequest(fakeAuth, { + repository: fakeRepository, + title: "Add feature", + body: "Body", + sourceBranch: "feature", + targetBranch: "main", + draft: true, + }); + + expect(result.state).toBe("draft"); + expect(mockFetchWithTimeout).toHaveBeenCalledTimes(1); + const sentBody = JSON.parse(mockFetchWithTimeout.mock.calls[0][1]?.body as string); + expect(sentBody.draft).toBe(true); + }); + + it("throws a SourceControlProviderError when PR creation fails", async () => { + mockFetchWithTimeout.mockResolvedValueOnce( + makeResponse("Validation failed: head branch does not exist", 422) + ); + + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + const err = await provider + .createPullRequest(fakeAuth, { + repository: fakeRepository, + title: "Add feature", + body: "Body", + sourceBranch: "feature", + targetBranch: "main", + draft: true, + }) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(SourceControlProviderError); + expect((err as SourceControlProviderError).httpStatus).toBe(422); + }); + }); }); diff --git a/packages/sandbox-runtime/src/sandbox_runtime/plugins/inspect-plugin.js b/packages/sandbox-runtime/src/sandbox_runtime/plugins/inspect-plugin.js index 3541983fe..62b7b9025 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/plugins/inspect-plugin.js +++ b/packages/sandbox-runtime/src/sandbox_runtime/plugins/inspect-plugin.js @@ -84,12 +84,19 @@ export default tool({ .describe( "Target branch to merge into. Defaults to the repository's default branch (usually 'main')." ), + draft: z + .boolean() + .optional() + .describe( + "Open the pull request as a draft. Set to true to open a draft PR, or false for a ready-for-review PR. Note: this may be overridden by policy." + ), }, async execute(args, context) { console.log(`[create-pull-request] execute() called with args:`, JSON.stringify(args)); const title = args.title || "Changes from OpenCode session"; const body = args.body || "Automated PR created via create-pull-request tool"; const baseBranch = args.baseBranch; // undefined if not provided, server will use default + const draft = args.draft; // undefined if not provided, server falls back to repo setting const headBranch = await getCurrentBranch(); try { @@ -118,6 +125,7 @@ export default tool({ body: body, baseBranch: baseBranch, headBranch: headBranch, + draft: draft, timestamp: Date.now(), }), }); diff --git a/packages/shared/src/types/integrations.ts b/packages/shared/src/types/integrations.ts index 6880d1549..7fc2a48f1 100644 --- a/packages/shared/src/types/integrations.ts +++ b/packages/shared/src/types/integrations.ts @@ -2,6 +2,7 @@ import { escapeRegExp } from "../regex"; +/** Third-party integrations, each surfaced as a card in the Integrations settings list. */ export type IntegrationId = "github" | "linear" | "code-server" | "sandbox" | "slack"; /** Enforces the common shape for all integration configurations. */ @@ -26,6 +27,16 @@ export interface GitHubBotSettings { commentActionInstructions?: string; } +/** + * Source-control (SCM) behavior settings. + * + * Provider-agnostic: applies to both GitHub and GitLab. + */ +export interface ScmSettings { + /** Always open pull/merge requests created by sessions as drafts. */ + alwaysUseDraftMode?: boolean; +} + /** Overridable behavior settings for the Linear bot. Used at both global (defaults) and per-repo (overrides) levels. */ export interface LinearBotSettings { model?: string; @@ -263,13 +274,18 @@ export function matchRoutingRules(message: string, rules: SlackRoutingRule[]): S }); } -/** Maps each integration ID to its global and per-repo settings types. */ +/** + * Maps each settings-store key to its global and per-repo settings types. Keys + * are the integration IDs plus top-level settings that share the + * same generic settings store for storage only. + */ export interface IntegrationSettingsMap { github: IntegrationEntry; linear: IntegrationEntry; "code-server": IntegrationEntry; sandbox: IntegrationEntry; slack: IntegrationEntry; + scm: IntegrationEntry; } /** Derived type for the GitHub bot global config. */ @@ -277,6 +293,7 @@ export type GitHubGlobalConfig = IntegrationSettingsMap["github"]["global"]; export type LinearGlobalConfig = IntegrationSettingsMap["linear"]["global"]; export type CodeServerGlobalConfig = IntegrationSettingsMap["code-server"]["global"]; export type SandboxGlobalConfig = IntegrationSettingsMap["sandbox"]["global"]; +export type ScmGlobalConfig = IntegrationSettingsMap["scm"]["global"]; export type SlackGlobalConfig = IntegrationSettingsMap["slack"]["global"]; /** Full MCP server config with decrypted credentials. Internal use only. */ diff --git a/packages/web/src/app/(app)/settings/page.tsx b/packages/web/src/app/(app)/settings/page.tsx index 576ae6c1d..b1803938b 100644 --- a/packages/web/src/app/(app)/settings/page.tsx +++ b/packages/web/src/app/(app)/settings/page.tsx @@ -10,6 +10,7 @@ import { DataControlsSettings } from "@/components/settings/data-controls-settin import { KeyboardShortcutsSettings } from "@/components/settings/keyboard-shortcuts-settings"; import { IntegrationsSettings } from "@/components/settings/integrations-settings"; import { SandboxSettingsPage } from "@/components/settings/sandbox-settings"; +import { ScmSettingsPage } from "@/components/settings/scm-settings"; import { ImagesSettings } from "@/components/settings/images-settings"; import { McpServersSettings } from "@/components/settings/mcp-servers-settings"; import { AppearanceSettings } from "@/components/settings/appearance-settings"; @@ -26,6 +27,7 @@ const CATEGORY_LABELS: Record = { "keyboard-shortcuts": "Keyboard", "data-controls": "Data Controls", sandbox: "Sandbox", + scm: "SCM Settings", integrations: "Integrations", "mcp-servers": "MCP Servers", }; @@ -38,6 +40,7 @@ const VALID_CATEGORIES = new Set([ "keyboard-shortcuts", "data-controls", "sandbox", + "scm", "integrations", "mcp-servers", ]); @@ -87,6 +90,7 @@ export default function SettingsPage() { {activeCategory === "keyboard-shortcuts" && } {activeCategory === "data-controls" && } {activeCategory === "sandbox" && } + {activeCategory === "scm" && } {activeCategory === "integrations" && } {activeCategory === "mcp-servers" && } diff --git a/packages/web/src/app/api/scm-settings/repos/[owner]/[name]/route.ts b/packages/web/src/app/api/scm-settings/repos/[owner]/[name]/route.ts new file mode 100644 index 000000000..b007fb699 --- /dev/null +++ b/packages/web/src/app/api/scm-settings/repos/[owner]/[name]/route.ts @@ -0,0 +1,59 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { controlPlaneFetch } from "@/lib/control-plane"; + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ owner: string; name: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { owner, name } = await params; + + try { + const body = await request.json(); + const response = await controlPlaneFetch( + `/scm-settings/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, + { + method: "PUT", + body: JSON.stringify(body), + } + ); + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + console.error("Failed to update SCM repo settings:", error); + return NextResponse.json({ error: "Failed to update SCM repo settings" }, { status: 500 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ owner: string; name: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { owner, name } = await params; + + try { + const response = await controlPlaneFetch( + `/scm-settings/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, + { + method: "DELETE", + } + ); + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + console.error("Failed to delete SCM repo settings:", error); + return NextResponse.json({ error: "Failed to delete SCM repo settings" }, { status: 500 }); + } +} diff --git a/packages/web/src/app/api/scm-settings/repos/route.ts b/packages/web/src/app/api/scm-settings/repos/route.ts new file mode 100644 index 000000000..7e2d5e775 --- /dev/null +++ b/packages/web/src/app/api/scm-settings/repos/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { controlPlaneFetch } from "@/lib/control-plane"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const response = await controlPlaneFetch("/scm-settings/repos"); + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + console.error("Failed to fetch SCM repo settings:", error); + return NextResponse.json({ error: "Failed to fetch SCM repo settings" }, { status: 500 }); + } +} diff --git a/packages/web/src/app/api/scm-settings/route.ts b/packages/web/src/app/api/scm-settings/route.ts new file mode 100644 index 000000000..9c69cf6e3 --- /dev/null +++ b/packages/web/src/app/api/scm-settings/route.ts @@ -0,0 +1,57 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { controlPlaneFetch } from "@/lib/control-plane"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const response = await controlPlaneFetch("/scm-settings"); + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + console.error("Failed to fetch SCM settings:", error); + return NextResponse.json({ error: "Failed to fetch SCM settings" }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await request.json(); + const response = await controlPlaneFetch("/scm-settings", { + method: "PUT", + body: JSON.stringify(body), + }); + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + console.error("Failed to update SCM settings:", error); + return NextResponse.json({ error: "Failed to update SCM settings" }, { status: 500 }); + } +} + +export async function DELETE() { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const response = await controlPlaneFetch("/scm-settings", { method: "DELETE" }); + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (error) { + console.error("Failed to delete SCM settings:", error); + return NextResponse.json({ error: "Failed to delete SCM settings" }, { status: 500 }); + } +} diff --git a/packages/web/src/components/settings/scm-settings.tsx b/packages/web/src/components/settings/scm-settings.tsx new file mode 100644 index 000000000..1e1142d18 --- /dev/null +++ b/packages/web/src/components/settings/scm-settings.tsx @@ -0,0 +1,397 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import useSWR, { mutate } from "swr"; +import { toast } from "sonner"; +import { + type EnrichedRepository, + type ScmSettings, + type ScmGlobalConfig, +} from "@open-inspect/shared"; +import { IntegrationSettingsSkeleton } from "./integrations/integration-settings-skeleton"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +const GLOBAL_SETTINGS_KEY = "/api/scm-settings"; +const REPO_SETTINGS_KEY = "/api/scm-settings/repos"; + +interface GlobalResponse { + settings: ScmGlobalConfig | null; +} + +interface RepoSettingsEntry { + repo: string; + settings: ScmSettings; +} + +interface RepoListResponse { + repos: RepoSettingsEntry[]; +} + +interface ReposResponse { + repos: EnrichedRepository[]; +} + +export function ScmSettingsPage() { + const { data: globalData, isLoading: globalLoading } = + useSWR(GLOBAL_SETTINGS_KEY); + const { data: repoSettingsData, isLoading: repoSettingsLoading } = + useSWR(REPO_SETTINGS_KEY); + const { data: reposData } = useSWR("/api/repos"); + + if (globalLoading || repoSettingsLoading) { + return ; + } + + const settings = globalData?.settings; + const repoOverrides = repoSettingsData?.repos ?? []; + const availableRepos = reposData?.repos ?? []; + + return ( +
+

+ Source Code Management Settings +

+

+ Defaults for pull and merge requests opened by coding sessions. +

+ + + +
+ +
+
+ ); +} + +function GlobalSettingsSection({ settings }: { settings: ScmGlobalConfig | null | undefined }) { + const [alwaysUseDraftMode, setAlwaysUseDraftMode] = useState( + settings?.defaults?.alwaysUseDraftMode ?? false + ); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [initialized, setInitialized] = useState(false); + const [showResetDialog, setShowResetDialog] = useState(false); + + useEffect(() => { + if (settings !== undefined && !initialized) { + if (settings) { + setAlwaysUseDraftMode(settings.defaults?.alwaysUseDraftMode ?? false); + } + setInitialized(true); + } + }, [settings, initialized]); + + const isConfigured = settings !== null && settings !== undefined; + + const handleConfirmReset = async () => { + setSaving(true); + + try { + const res = await fetch(GLOBAL_SETTINGS_KEY, { method: "DELETE" }); + + if (res.ok) { + mutate(GLOBAL_SETTINGS_KEY); + setAlwaysUseDraftMode(false); + setDirty(false); + toast.success("Settings reset to defaults."); + } else { + const data = await res.json(); + toast.error(data.error || "Failed to reset settings"); + } + } catch { + toast.error("Failed to reset settings"); + } finally { + setSaving(false); + } + }; + + const handleSave = async () => { + setSaving(true); + + const defaults: ScmSettings = { alwaysUseDraftMode }; + const body: ScmGlobalConfig = { defaults }; + + try { + const res = await fetch(GLOBAL_SETTINGS_KEY, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: body }), + }); + + if (res.ok) { + mutate(GLOBAL_SETTINGS_KEY); + toast.success("Settings saved."); + setDirty(false); + } else { + const data = await res.json(); + toast.error(data.error || "Failed to save settings"); + } + } catch { + toast.error("Failed to save settings"); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ +
+ +
+ + + {isConfigured && ( + + )} +
+ + + + + Reset to defaults + + Reset the global pull/merge request defaults? Per-repository overrides will not be + affected. + + + + Cancel + Reset + + + +
+ ); +} + +function RepoOverridesSection({ + overrides, + availableRepos, + globalDefault, +}: { + overrides: RepoSettingsEntry[]; + availableRepos: EnrichedRepository[]; + globalDefault: boolean; +}) { + const [addingRepo, setAddingRepo] = useState(""); + + const overriddenRepos = new Set(overrides.map((o) => o.repo)); + const availableForOverride = availableRepos.filter( + (r) => !overriddenRepos.has(r.fullName.toLowerCase()) + ); + + const handleAdd = async () => { + if (!addingRepo) return; + const [owner, name] = addingRepo.split("/"); + + try { + const res = await fetch(`/api/scm-settings/repos/${owner}/${name}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + // Seed the new override from the current global default so adding one + // doesn't silently flip a repo to draft when the global is off. + body: JSON.stringify({ settings: { alwaysUseDraftMode: globalDefault } }), + }); + + if (res.ok) { + mutate(REPO_SETTINGS_KEY); + setAddingRepo(""); + toast.success("Override added."); + } else { + const data = await res.json(); + toast.error(data.error || "Failed to add override"); + } + } catch { + toast.error("Failed to add override"); + } + }; + + return ( +
+ {overrides.length > 0 ? ( +
+ {overrides.map((entry) => ( + + ))} +
+ ) : ( +

+ No repository overrides yet. Add one to set the draft default per repo. +

+ )} + +
+ + +
+
+ ); +} + +function RepoOverrideRow({ entry }: { entry: RepoSettingsEntry }) { + const [alwaysUseDraftMode, setAlwaysUseDraftMode] = useState( + entry.settings.alwaysUseDraftMode ?? false + ); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + + const handleSave = async () => { + setSaving(true); + + const [owner, name] = entry.repo.split("/"); + const settings: ScmSettings = { alwaysUseDraftMode }; + + try { + const res = await fetch(`/api/scm-settings/repos/${owner}/${name}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings }), + }); + + if (res.ok) { + mutate(REPO_SETTINGS_KEY); + setDirty(false); + toast.success(`Override for ${entry.repo} saved.`); + } else { + const data = await res.json(); + toast.error(data.error || "Failed to save override"); + } + } catch { + toast.error("Failed to save override"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + const [owner, name] = entry.repo.split("/"); + + try { + const res = await fetch(`/api/scm-settings/repos/${owner}/${name}`, { + method: "DELETE", + }); + + if (res.ok) { + mutate(REPO_SETTINGS_KEY); + toast.success(`Override for ${entry.repo} removed.`); + } else { + const data = await res.json(); + toast.error(data.error || "Failed to delete override"); + } + } catch { + toast.error("Failed to delete override"); + } + }; + + return ( +
+
+ {entry.repo} + +
+ +
+ + +
+
+ ); +} + +function Section({ + title, + description, + children, +}: { + title: string; + description: string; + children: ReactNode; +}) { + return ( +
+

+ {title} +

+

{description}

+ {children} +
+ ); +} diff --git a/packages/web/src/components/settings/settings-nav.tsx b/packages/web/src/components/settings/settings-nav.tsx index b549f6b6a..0eef6ceda 100644 --- a/packages/web/src/components/settings/settings-nav.tsx +++ b/packages/web/src/components/settings/settings-nav.tsx @@ -10,6 +10,7 @@ import { IntegrationsIcon, AppearanceIcon, TerminalIcon, + GitPrIcon, ChevronRightIcon, } from "@/components/ui/icons"; import { supportsRepoImages } from "@/lib/sandbox-provider"; @@ -50,6 +51,11 @@ const NAV_ITEMS = [ label: "Sandbox", icon: TerminalIcon, }, + { + id: "scm", + label: "SCM Settings", + icon: GitPrIcon, + }, { id: "integrations", label: "Integrations",