From ac30cbacb15cab185a8c9315977553267c2a2b28 Mon Sep 17 00:00:00 2001 From: Jehiah Czebotar Date: Wed, 17 Jun 2026 17:41:03 -0400 Subject: [PATCH 1/7] feat: PR request draft mode setting --- .../src/db/integration-settings.test.ts | 34 ++ .../src/db/integration-settings.ts | 14 + .../src/routes/integration-settings.ts | 13 + .../src/routes/session-runtime-proxy.test.ts | 45 ++ .../src/routes/session-runtime-proxy.ts | 5 + .../src/session/durable-object.ts | 25 ++ .../http/handlers/pull-request.handler.ts | 1 + .../src/session/pull-request-service.test.ts | 44 +- .../src/session/pull-request-service.ts | 18 + .../sandbox_runtime/plugins/inspect-plugin.js | 8 + packages/shared/src/types/integrations.ts | 17 + .../github-pr-integration-settings.tsx | 391 ++++++++++++++++++ 12 files changed, 614 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/components/settings/integrations/github-pr-integration-settings.tsx diff --git a/packages/control-plane/src/db/integration-settings.test.ts b/packages/control-plane/src/db/integration-settings.test.ts index 9d44aa61e..3bbfa38cd 100644 --- a/packages/control-plane/src/db/integration-settings.test.ts +++ b/packages/control-plane/src/db/integration-settings.test.ts @@ -878,6 +878,40 @@ describe("IntegrationSettingsStore", () => { }); }); + describe("github-pr settings", () => { + it("round-trips global github-pr settings", async () => { + await store.setGlobal("github-pr", { + defaults: { alwaysUseDraftMode: true }, + }); + + const result = await store.getGlobal("github-pr"); + expect(result).toEqual({ defaults: { alwaysUseDraftMode: true } }); + }); + + it("round-trips github-pr repo settings", async () => { + await store.setRepoSettings("github-pr", "acme/web", { alwaysUseDraftMode: true }); + + const result = await store.getRepoSettings("github-pr", "acme/web"); + expect(result).toEqual({ alwaysUseDraftMode: true }); + }); + + it("rejects non-boolean alwaysUseDraftMode", async () => { + await expect( + store.setGlobal("github-pr", { + defaults: { alwaysUseDraftMode: "yes" as unknown as boolean }, + }) + ).rejects.toThrow(IntegrationSettingsValidationError); + }); + + it("lets a repo override flip the global draft default", async () => { + await store.setGlobal("github-pr", { defaults: { alwaysUseDraftMode: true } }); + await store.setRepoSettings("github-pr", "acme/web", { alwaysUseDraftMode: false }); + + const config = await store.getResolvedConfig("github-pr", "acme/web"); + expect(config.settings).toEqual({ alwaysUseDraftMode: false }); + }); + }); + describe("slack settings", () => { it("round-trips global slack settings", async () => { await store.setGlobal("slack", { diff --git a/packages/control-plane/src/db/integration-settings.ts b/packages/control-plane/src/db/integration-settings.ts index 6ebcc4eea..7e4569bb3 100644 --- a/packages/control-plane/src/db/integration-settings.ts +++ b/packages/control-plane/src/db/integration-settings.ts @@ -9,6 +9,7 @@ import { type IntegrationId, type IntegrationSettingsMap, type GitHubBotSettings, + type GitHubPrSettings, type LinearBotSettings, type CodeServerSettings, type SlackGlobalSettings, @@ -222,6 +223,10 @@ export class IntegrationSettingsStore { ) as IntegrationSettingsMap[K]["repo"]; } + if (integrationId === "github-pr") { + this.validateGitHubPrSettings(settings as GitHubPrSettings); + } + if (integrationId === "linear") { this.validateLinearSettings(settings as LinearBotSettings); } @@ -298,6 +303,15 @@ export class IntegrationSettingsStore { return settings; } + private validateGitHubPrSettings(settings: GitHubPrSettings): void { + if ( + settings.alwaysUseDraftMode !== undefined && + typeof settings.alwaysUseDraftMode !== "boolean" + ) { + throw new IntegrationSettingsValidationError("alwaysUseDraftMode must be a boolean"); + } + } + private validateLinearSettings(settings: LinearBotSettings): void { this.validateModelAndEffort(settings); diff --git a/packages/control-plane/src/routes/integration-settings.ts b/packages/control-plane/src/routes/integration-settings.ts index 73bee0f16..5d11b9fc2 100644 --- a/packages/control-plane/src/routes/integration-settings.ts +++ b/packages/control-plane/src/routes/integration-settings.ts @@ -8,6 +8,7 @@ import { isValidReasoningEffort, type CodeServerSettings, type GitHubBotSettings, + type GitHubPrSettings, type IntegrationId, type LinearBotSettings, type SandboxSettings, @@ -318,6 +319,18 @@ async function handleGetResolvedConfig( }); } + if (id === "github-pr") { + const githubPrSettings = settings as GitHubPrSettings; + return json({ + integrationId: id, + repo, + config: { + alwaysUseDraftMode: githubPrSettings.alwaysUseDraftMode ?? false, + enabledRepos, + }, + }); + } + if (id === "linear") { const linearSettings = settings as LinearBotSettings; const linearReasoningEffort = 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..8ab7c7dea 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 != null && 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..900fd9dc4 100644 --- a/packages/control-plane/src/session/durable-object.ts +++ b/packages/control-plane/src/session/durable-object.ts @@ -496,6 +496,7 @@ export class SessionDO extends DurableObject { }); }, appName: resolveAppName(this.env), + resolveAlwaysDraftDefault: () => this.resolveAlwaysDraftDefault(), }); return pullRequestService.createPullRequest(input); @@ -516,6 +517,30 @@ export class SessionDO extends DurableObject { return this._participantsHandler; } + /** + * Resolves the "always use draft mode" GitHub 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 settingsStore = new IntegrationSettingsStore(this.env.DB); + const { settings } = await settingsStore.getResolvedConfig( + "github-pr", + `${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..100549a8a 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,47 @@ 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("lets an explicit draft=false override the always-draft default", async () => { + harness = createTestHarness({ alwaysDraftDefault: true }); + + await harness.service.createPullRequest(createInput({ draft: false })); + + expect(harness.provider.createPullRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ draft: false }) + ); + expect(harness.deps.resolveAlwaysDraftDefault).not.toHaveBeenCalled(); + }); + 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..0f1b3452a 100644 --- a/packages/control-plane/src/session/pull-request-service.ts +++ b/packages/control-plane/src/session/pull-request-service.ts @@ -21,6 +21,12 @@ export interface CreatePullRequestInput { promptingUserId: string; promptingAuth: SourceControlAuthContext | null; sessionUrl: string; + /** + * Whether to open the PR in draft mode. When undefined, the configured + * "always use draft mode" GitHub setting (global default + repo override) + * decides. An explicit value always wins over the setting. + */ + draft?: boolean; } export type CreatePullRequestResult = @@ -63,6 +69,13 @@ 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. Used only when the create-PR + * request does not specify `draft` explicitly. Defaults to `false` when the + * setting store is unavailable. + */ + resolveAlwaysDraftDefault: () => Promise; } /** @@ -188,12 +201,17 @@ export class SessionPullRequestService { const fullBody = input.body + `\n\n---\n*Created with [${this.deps.appName}](${input.sessionUrl})*`; + // An explicit `draft` on the request wins; otherwise fall back to the + // configured "always use draft mode" GitHub setting for this repo. + const draft = input.draft ?? (await this.deps.resolveAlwaysDraftDefault()); + 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/sandbox-runtime/src/sandbox_runtime/plugins/inspect-plugin.js b/packages/sandbox-runtime/src/sandbox_runtime/plugins/inspect-plugin.js index 3541983fe..1a9c589e9 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. When omitted, the repository's configured default is used (the 'always use draft mode' GitHub setting). Set to true to force a draft PR, or false to force a ready-for-review PR." + ), }, 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..7d7785ec8 100644 --- a/packages/shared/src/types/integrations.ts +++ b/packages/shared/src/types/integrations.ts @@ -26,6 +26,16 @@ export interface GitHubBotSettings { commentActionInstructions?: string; } +/** + * GitHub pull request behavior settings. Distinct from the GitHub bot integration: + * these control how sessions create PRs, not PR-review/comment automation. Used at + * both global (defaults) and per-repo (overrides) levels. + */ +export interface GitHubPrSettings { + /** Open pull requests created by sessions in draft mode by default. */ + alwaysUseDraftMode?: boolean; +} + /** Overridable behavior settings for the Linear bot. Used at both global (defaults) and per-repo (overrides) levels. */ export interface LinearBotSettings { model?: string; @@ -266,6 +276,7 @@ export function matchRoutingRules(message: string, rules: SlackRoutingRule[]): S /** Maps each integration ID to its global and per-repo settings types. */ export interface IntegrationSettingsMap { github: IntegrationEntry; + "github-pr": IntegrationEntry; linear: IntegrationEntry; "code-server": IntegrationEntry; sandbox: IntegrationEntry; @@ -274,6 +285,7 @@ export interface IntegrationSettingsMap { /** Derived type for the GitHub bot global config. */ export type GitHubGlobalConfig = IntegrationSettingsMap["github"]["global"]; +export type GitHubPrGlobalConfig = IntegrationSettingsMap["github-pr"]["global"]; export type LinearGlobalConfig = IntegrationSettingsMap["linear"]["global"]; export type CodeServerGlobalConfig = IntegrationSettingsMap["code-server"]["global"]; export type SandboxGlobalConfig = IntegrationSettingsMap["sandbox"]["global"]; @@ -315,6 +327,11 @@ export const INTEGRATION_DEFINITIONS: { name: "GitHub Bot", description: "Automated PR reviews and comment-triggered actions", }, + { + id: "github-pr", + name: "GitHub", + description: "Pull request defaults for coding sessions, like opening PRs as draft", + }, { id: "linear", name: "Linear Agent", diff --git a/packages/web/src/components/settings/integrations/github-pr-integration-settings.tsx b/packages/web/src/components/settings/integrations/github-pr-integration-settings.tsx new file mode 100644 index 000000000..0d83840a8 --- /dev/null +++ b/packages/web/src/components/settings/integrations/github-pr-integration-settings.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import useSWR, { mutate } from "swr"; +import { toast } from "sonner"; +import { + type EnrichedRepository, + type GitHubPrSettings, + type GitHubPrGlobalConfig, +} from "@open-inspect/shared"; +import { IntegrationSettingsSkeleton } from "./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/integration-settings/github-pr"; +const REPO_SETTINGS_KEY = "/api/integration-settings/github-pr/repos"; + +interface GlobalResponse { + settings: GitHubPrGlobalConfig | null; +} + +interface RepoSettingsEntry { + repo: string; + settings: GitHubPrSettings; +} + +interface RepoListResponse { + repos: RepoSettingsEntry[]; +} + +interface ReposResponse { + repos: EnrichedRepository[]; +} + +export function GitHubPrIntegrationSettings() { + 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 ( +
+

GitHub

+

+ Defaults for pull requests opened by coding sessions. These are separate from the GitHub Bot + settings, which control automated PR reviews and comment actions. +

+ + + +
+ +
+
+ ); +} + +function GlobalSettingsSection({ + settings, +}: { + settings: GitHubPrGlobalConfig | 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: GitHubPrSettings = { alwaysUseDraftMode }; + const body: GitHubPrGlobalConfig = { 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 request defaults? Per-repository overrides will not be affected. + + + + Cancel + Reset + + + +
+ ); +} + +function RepoOverridesSection({ + overrides, + availableRepos, +}: { + overrides: RepoSettingsEntry[]; + availableRepos: EnrichedRepository[]; +}) { + 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/integration-settings/github-pr/repos/${owner}/${name}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: { alwaysUseDraftMode: true } }), + }); + + 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: GitHubPrSettings = { alwaysUseDraftMode }; + + try { + const res = await fetch(`/api/integration-settings/github-pr/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/integration-settings/github-pr/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} +
+ ); +} From 2346f57da8b473270eaaf749550829d3f504d89e Mon Sep 17 00:00:00 2001 From: Jehiah Czebotar Date: Thu, 18 Jun 2026 09:28:16 -0400 Subject: [PATCH 2/7] address review comments --- .../src/routes/integration-settings.ts | 4 +- .../src/routes/session-runtime-proxy.ts | 2 +- .../src/session/pull-request-service.test.ts | 5 +- .../src/session/pull-request-service.ts | 9 +- .../providers/github-provider.test.ts | 93 +++++++++++++++++++ .../sandbox_runtime/plugins/inspect-plugin.js | 2 +- .../github-pr-integration-settings.tsx | 12 ++- 7 files changed, 116 insertions(+), 11 deletions(-) diff --git a/packages/control-plane/src/routes/integration-settings.ts b/packages/control-plane/src/routes/integration-settings.ts index 5d11b9fc2..4e005b0a1 100644 --- a/packages/control-plane/src/routes/integration-settings.ts +++ b/packages/control-plane/src/routes/integration-settings.ts @@ -320,13 +320,15 @@ async function handleGetResolvedConfig( } if (id === "github-pr") { + // github-pr has no enable/disable-per-repo concept, so `enabledRepos` is + // omitted here. This branch exists for parity with the other integrations' + // resolved-config endpoints and as a hook for future runtime callers. const githubPrSettings = settings as GitHubPrSettings; return json({ integrationId: id, repo, config: { alwaysUseDraftMode: githubPrSettings.alwaysUseDraftMode ?? false, - enabledRepos, }, }); } diff --git a/packages/control-plane/src/routes/session-runtime-proxy.ts b/packages/control-plane/src/routes/session-runtime-proxy.ts index 8ab7c7dea..82dde368b 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.ts @@ -94,7 +94,7 @@ async function handleCreatePR( return error("headBranch must be a string"); } - if (body.draft != null && typeof body.draft !== "boolean") { + if (body.draft !== undefined && typeof body.draft !== "boolean") { return error("draft must be a boolean"); } 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 100549a8a..cd5202187 100644 --- a/packages/control-plane/src/session/pull-request-service.test.ts +++ b/packages/control-plane/src/session/pull-request-service.test.ts @@ -288,16 +288,15 @@ describe("SessionPullRequestService", () => { ); }); - it("lets an explicit draft=false override the always-draft default", async () => { + 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: false }) + expect.objectContaining({ draft: true }) ); - expect(harness.deps.resolveAlwaysDraftDefault).not.toHaveBeenCalled(); }); it("creates PR with OAuth token and stores PR artifact", async () => { diff --git a/packages/control-plane/src/session/pull-request-service.ts b/packages/control-plane/src/session/pull-request-service.ts index 0f1b3452a..360926955 100644 --- a/packages/control-plane/src/session/pull-request-service.ts +++ b/packages/control-plane/src/session/pull-request-service.ts @@ -201,9 +201,12 @@ export class SessionPullRequestService { const fullBody = input.body + `\n\n---\n*Created with [${this.deps.appName}](${input.sessionUrl})*`; - // An explicit `draft` on the request wins; otherwise fall back to the - // configured "always use draft mode" GitHub setting for this repo. - const draft = input.draft ?? (await this.deps.resolveAlwaysDraftDefault()); + // "Always use draft mode" 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, 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 1a9c589e9..62b7b9025 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/plugins/inspect-plugin.js +++ b/packages/sandbox-runtime/src/sandbox_runtime/plugins/inspect-plugin.js @@ -88,7 +88,7 @@ export default tool({ .boolean() .optional() .describe( - "Open the pull request as a draft. When omitted, the repository's configured default is used (the 'always use draft mode' GitHub setting). Set to true to force a draft PR, or false to force a ready-for-review PR." + "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) { diff --git a/packages/web/src/components/settings/integrations/github-pr-integration-settings.tsx b/packages/web/src/components/settings/integrations/github-pr-integration-settings.tsx index 0d83840a8..652c1c0f9 100644 --- a/packages/web/src/components/settings/integrations/github-pr-integration-settings.tsx +++ b/packages/web/src/components/settings/integrations/github-pr-integration-settings.tsx @@ -77,7 +77,11 @@ export function GitHubPrIntegrationSettings() { title="Repository Overrides" description="Override the draft default for specific repositories." > - + ); @@ -215,9 +219,11 @@ function GlobalSettingsSection({ function RepoOverridesSection({ overrides, availableRepos, + globalDefault, }: { overrides: RepoSettingsEntry[]; availableRepos: EnrichedRepository[]; + globalDefault: boolean; }) { const [addingRepo, setAddingRepo] = useState(""); @@ -234,7 +240,9 @@ function RepoOverridesSection({ const res = await fetch(`/api/integration-settings/github-pr/repos/${owner}/${name}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ settings: { alwaysUseDraftMode: true } }), + // 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) { From 1fd9afb50dc05470f6a0c1ceb025cbea6ade5600 Mon Sep 17 00:00:00 2001 From: Jehiah Czebotar Date: Thu, 18 Jun 2026 10:05:12 -0400 Subject: [PATCH 3/7] =?UTF-8?q?Renamed=20integration=20id=20github-pr=20?= =?UTF-8?q?=E2=86=92=20scm,=20type=20GitHubPrSettings=20=E2=86=92=20ScmSet?= =?UTF-8?q?tings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/db/integration-settings.test.ts | 22 ++++----- .../src/db/integration-settings.ts | 12 ++--- .../src/routes/integration-settings.ts | 12 ++--- .../src/session/durable-object.ts | 4 +- .../src/session/pull-request-service.ts | 8 ++-- packages/shared/src/types/integrations.ts | 20 ++++---- packages/web/src/app/(app)/settings/page.tsx | 4 ++ ...egration-settings.tsx => scm-settings.tsx} | 48 +++++++++---------- .../src/components/settings/settings-nav.tsx | 6 +++ 9 files changed, 70 insertions(+), 66 deletions(-) rename packages/web/src/components/settings/{integrations/github-pr-integration-settings.tsx => scm-settings.tsx} (87%) diff --git a/packages/control-plane/src/db/integration-settings.test.ts b/packages/control-plane/src/db/integration-settings.test.ts index 3bbfa38cd..eeced8a4b 100644 --- a/packages/control-plane/src/db/integration-settings.test.ts +++ b/packages/control-plane/src/db/integration-settings.test.ts @@ -878,36 +878,36 @@ describe("IntegrationSettingsStore", () => { }); }); - describe("github-pr settings", () => { - it("round-trips global github-pr settings", async () => { - await store.setGlobal("github-pr", { + describe("scm settings", () => { + it("round-trips global scm settings", async () => { + await store.setGlobal("scm", { defaults: { alwaysUseDraftMode: true }, }); - const result = await store.getGlobal("github-pr"); + const result = await store.getGlobal("scm"); expect(result).toEqual({ defaults: { alwaysUseDraftMode: true } }); }); - it("round-trips github-pr repo settings", async () => { - await store.setRepoSettings("github-pr", "acme/web", { alwaysUseDraftMode: true }); + it("round-trips scm repo settings", async () => { + await store.setRepoSettings("scm", "acme/web", { alwaysUseDraftMode: true }); - const result = await store.getRepoSettings("github-pr", "acme/web"); + const result = await store.getRepoSettings("scm", "acme/web"); expect(result).toEqual({ alwaysUseDraftMode: true }); }); it("rejects non-boolean alwaysUseDraftMode", async () => { await expect( - store.setGlobal("github-pr", { + store.setGlobal("scm", { defaults: { alwaysUseDraftMode: "yes" as unknown as boolean }, }) ).rejects.toThrow(IntegrationSettingsValidationError); }); it("lets a repo override flip the global draft default", async () => { - await store.setGlobal("github-pr", { defaults: { alwaysUseDraftMode: true } }); - await store.setRepoSettings("github-pr", "acme/web", { alwaysUseDraftMode: false }); + await store.setGlobal("scm", { defaults: { alwaysUseDraftMode: true } }); + await store.setRepoSettings("scm", "acme/web", { alwaysUseDraftMode: false }); - const config = await store.getResolvedConfig("github-pr", "acme/web"); + const config = await store.getResolvedConfig("scm", "acme/web"); expect(config.settings).toEqual({ alwaysUseDraftMode: false }); }); }); diff --git a/packages/control-plane/src/db/integration-settings.ts b/packages/control-plane/src/db/integration-settings.ts index 7e4569bb3..a74fafc21 100644 --- a/packages/control-plane/src/db/integration-settings.ts +++ b/packages/control-plane/src/db/integration-settings.ts @@ -1,7 +1,7 @@ import { isValidModel, isValidReasoningEffort, - INTEGRATION_DEFINITIONS, + INTEGRATION_IDS, DEFAULT_MENTIONS_POLICY, MAX_SLACK_ROUTING_RULES, MAX_SLACK_ROUTING_KEYWORD_LENGTH, @@ -9,7 +9,7 @@ import { type IntegrationId, type IntegrationSettingsMap, type GitHubBotSettings, - type GitHubPrSettings, + type ScmSettings, type LinearBotSettings, type CodeServerSettings, type SlackGlobalSettings, @@ -29,7 +29,7 @@ export class IntegrationSettingsValidationError extends Error { } } -const VALID_INTEGRATION_IDS = new Set(INTEGRATION_DEFINITIONS.map((d) => d.id)); +const VALID_INTEGRATION_IDS = new Set(INTEGRATION_IDS); export function isValidIntegrationId(id: string): id is IntegrationId { return VALID_INTEGRATION_IDS.has(id); @@ -223,8 +223,8 @@ export class IntegrationSettingsStore { ) as IntegrationSettingsMap[K]["repo"]; } - if (integrationId === "github-pr") { - this.validateGitHubPrSettings(settings as GitHubPrSettings); + if (integrationId === "scm") { + this.validateScmSettings(settings as ScmSettings); } if (integrationId === "linear") { @@ -303,7 +303,7 @@ export class IntegrationSettingsStore { return settings; } - private validateGitHubPrSettings(settings: GitHubPrSettings): void { + private validateScmSettings(settings: ScmSettings): void { if ( settings.alwaysUseDraftMode !== undefined && typeof settings.alwaysUseDraftMode !== "boolean" diff --git a/packages/control-plane/src/routes/integration-settings.ts b/packages/control-plane/src/routes/integration-settings.ts index 4e005b0a1..118a4c9ef 100644 --- a/packages/control-plane/src/routes/integration-settings.ts +++ b/packages/control-plane/src/routes/integration-settings.ts @@ -8,7 +8,7 @@ import { isValidReasoningEffort, type CodeServerSettings, type GitHubBotSettings, - type GitHubPrSettings, + type ScmSettings, type IntegrationId, type LinearBotSettings, type SandboxSettings, @@ -319,16 +319,16 @@ async function handleGetResolvedConfig( }); } - if (id === "github-pr") { - // github-pr has no enable/disable-per-repo concept, so `enabledRepos` is - // omitted here. This branch exists for parity with the other integrations' + if (id === "scm") { + // scm has no enable/disable-per-repo concept, so `enabledRepos` is omitted + // here. This branch exists for parity with the other integrations' // resolved-config endpoints and as a hook for future runtime callers. - const githubPrSettings = settings as GitHubPrSettings; + const scmSettings = settings as ScmSettings; return json({ integrationId: id, repo, config: { - alwaysUseDraftMode: githubPrSettings.alwaysUseDraftMode ?? false, + alwaysUseDraftMode: scmSettings.alwaysUseDraftMode ?? false, }, }); } diff --git a/packages/control-plane/src/session/durable-object.ts b/packages/control-plane/src/session/durable-object.ts index 900fd9dc4..3bf9dbb91 100644 --- a/packages/control-plane/src/session/durable-object.ts +++ b/packages/control-plane/src/session/durable-object.ts @@ -518,7 +518,7 @@ export class SessionDO extends DurableObject { } /** - * Resolves the "always use draft mode" GitHub setting (global default merged + * 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. */ @@ -529,7 +529,7 @@ export class SessionDO extends DurableObject { try { const settingsStore = new IntegrationSettingsStore(this.env.DB); const { settings } = await settingsStore.getResolvedConfig( - "github-pr", + "scm", `${session.repo_owner}/${session.repo_name}` ); return settings.alwaysUseDraftMode === true; diff --git a/packages/control-plane/src/session/pull-request-service.ts b/packages/control-plane/src/session/pull-request-service.ts index 360926955..9f233decd 100644 --- a/packages/control-plane/src/session/pull-request-service.ts +++ b/packages/control-plane/src/session/pull-request-service.ts @@ -201,10 +201,10 @@ export class SessionPullRequestService { const fullBody = input.body + `\n\n---\n*Created with [${this.deps.appName}](${input.sessionUrl})*`; - // "Always use draft mode" 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). + // 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); diff --git a/packages/shared/src/types/integrations.ts b/packages/shared/src/types/integrations.ts index 7d7785ec8..3453dc7cb 100644 --- a/packages/shared/src/types/integrations.ts +++ b/packages/shared/src/types/integrations.ts @@ -27,12 +27,13 @@ export interface GitHubBotSettings { } /** - * GitHub pull request behavior settings. Distinct from the GitHub bot integration: - * these control how sessions create PRs, not PR-review/comment automation. Used at - * both global (defaults) and per-repo (overrides) levels. + * Source-control (SCM) behavior settings. Provider-agnostic — applies to both + * GitHub and GitLab. These control how sessions create pull/merge requests, not + * the GitHub bot's PR-review/comment automation. Used at both global (defaults) + * and per-repo (overrides) levels. */ -export interface GitHubPrSettings { - /** Open pull requests created by sessions in draft mode by default. */ +export interface ScmSettings { + /** Always open pull/merge requests created by sessions as drafts. */ alwaysUseDraftMode?: boolean; } @@ -276,7 +277,7 @@ export function matchRoutingRules(message: string, rules: SlackRoutingRule[]): S /** Maps each integration ID to its global and per-repo settings types. */ export interface IntegrationSettingsMap { github: IntegrationEntry; - "github-pr": IntegrationEntry; + scm: IntegrationEntry; linear: IntegrationEntry; "code-server": IntegrationEntry; sandbox: IntegrationEntry; @@ -285,7 +286,7 @@ export interface IntegrationSettingsMap { /** Derived type for the GitHub bot global config. */ export type GitHubGlobalConfig = IntegrationSettingsMap["github"]["global"]; -export type GitHubPrGlobalConfig = IntegrationSettingsMap["github-pr"]["global"]; +export type ScmGlobalConfig = IntegrationSettingsMap["scm"]["global"]; export type LinearGlobalConfig = IntegrationSettingsMap["linear"]["global"]; export type CodeServerGlobalConfig = IntegrationSettingsMap["code-server"]["global"]; export type SandboxGlobalConfig = IntegrationSettingsMap["sandbox"]["global"]; @@ -327,11 +328,6 @@ export const INTEGRATION_DEFINITIONS: { name: "GitHub Bot", description: "Automated PR reviews and comment-triggered actions", }, - { - id: "github-pr", - name: "GitHub", - description: "Pull request defaults for coding sessions, like opening PRs as draft", - }, { id: "linear", name: "Linear Agent", 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/components/settings/integrations/github-pr-integration-settings.tsx b/packages/web/src/components/settings/scm-settings.tsx similarity index 87% rename from packages/web/src/components/settings/integrations/github-pr-integration-settings.tsx rename to packages/web/src/components/settings/scm-settings.tsx index 652c1c0f9..4b1bd6fb8 100644 --- a/packages/web/src/components/settings/integrations/github-pr-integration-settings.tsx +++ b/packages/web/src/components/settings/scm-settings.tsx @@ -5,10 +5,10 @@ import useSWR, { mutate } from "swr"; import { toast } from "sonner"; import { type EnrichedRepository, - type GitHubPrSettings, - type GitHubPrGlobalConfig, + type ScmSettings, + type ScmGlobalConfig, } from "@open-inspect/shared"; -import { IntegrationSettingsSkeleton } from "./integration-settings-skeleton"; +import { IntegrationSettingsSkeleton } from "./integrations/integration-settings-skeleton"; import { Button } from "@/components/ui/button"; import { Select, @@ -28,16 +28,16 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; -const GLOBAL_SETTINGS_KEY = "/api/integration-settings/github-pr"; -const REPO_SETTINGS_KEY = "/api/integration-settings/github-pr/repos"; +const GLOBAL_SETTINGS_KEY = "/api/integration-settings/scm"; +const REPO_SETTINGS_KEY = "/api/integration-settings/scm/repos"; interface GlobalResponse { - settings: GitHubPrGlobalConfig | null; + settings: ScmGlobalConfig | null; } interface RepoSettingsEntry { repo: string; - settings: GitHubPrSettings; + settings: ScmSettings; } interface RepoListResponse { @@ -48,7 +48,7 @@ interface ReposResponse { repos: EnrichedRepository[]; } -export function GitHubPrIntegrationSettings() { +export function ScmSettingsPage() { const { data: globalData, isLoading: globalLoading } = useSWR(GLOBAL_SETTINGS_KEY); const { data: repoSettingsData, isLoading: repoSettingsLoading } = @@ -65,10 +65,11 @@ export function GitHubPrIntegrationSettings() { return (
-

GitHub

+

SCM Settings

- Defaults for pull requests opened by coding sessions. These are separate from the GitHub Bot - settings, which control automated PR reviews and comment actions. + Defaults for pull and merge requests opened by coding sessions. These apply to both GitHub + and GitLab repositories, and are separate from the GitHub Bot settings (which control + automated PR reviews and comment actions).

@@ -87,11 +88,7 @@ export function GitHubPrIntegrationSettings() { ); } -function GlobalSettingsSection({ - settings, -}: { - settings: GitHubPrGlobalConfig | null | undefined; -}) { +function GlobalSettingsSection({ settings }: { settings: ScmGlobalConfig | null | undefined }) { const [alwaysUseDraftMode, setAlwaysUseDraftMode] = useState( settings?.defaults?.alwaysUseDraftMode ?? false ); @@ -136,8 +133,8 @@ function GlobalSettingsSection({ const handleSave = async () => { setSaving(true); - const defaults: GitHubPrSettings = { alwaysUseDraftMode }; - const body: GitHubPrGlobalConfig = { defaults }; + const defaults: ScmSettings = { alwaysUseDraftMode }; + const body: ScmGlobalConfig = { defaults }; try { const res = await fetch(GLOBAL_SETTINGS_KEY, { @@ -164,14 +161,14 @@ function GlobalSettingsSection({ return (