diff --git a/openapi.yaml b/openapi.yaml index 28ecbf7a..dcf685a6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -619,6 +619,39 @@ paths: $ref: '#/components/responses/TypedError' '404': $ref: '#/components/responses/TypedError' + /api/settings/github: + get: + tags: [Settings] + summary: Read GitHub commit and PR instructions + responses: + '200': + description: GitHub workflow instructions. + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsGithubResponse' + '404': + $ref: '#/components/responses/TypedError' + patch: + tags: [Settings] + summary: Update GitHub commit and PR instructions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsGithubUpdateRequest' + responses: + '200': + description: Updated GitHub workflow instructions. + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsGithubResponse' + '400': + $ref: '#/components/responses/TypedError' + '404': + $ref: '#/components/responses/TypedError' /api/workspaces/{workspaceId}/projects: parameters: - $ref: '#/components/parameters/WorkspaceId' @@ -846,6 +879,15 @@ components: type: array items: $ref: '#/components/schemas/SettingsReasoningEffort' + SettingsGithubResponse: + type: object + additionalProperties: false + required: [commitInstruction, prInstruction] + properties: + commitInstruction: + type: string + prInstruction: + type: string SettingsModelStage: type: object additionalProperties: false @@ -884,6 +926,14 @@ components: type: array items: $ref: '#/components/schemas/SettingsModelStageUpdate' + SettingsGithubUpdateRequest: + type: object + additionalProperties: false + properties: + commitInstruction: + type: string + prInstruction: + type: string SettingsModelStageUpdate: type: object additionalProperties: false diff --git a/packages/cli/src/features/config/env.ts b/packages/cli/src/features/config/env.ts index f0d63eef..9fd1a183 100644 --- a/packages/cli/src/features/config/env.ts +++ b/packages/cli/src/features/config/env.ts @@ -15,6 +15,10 @@ import { parseOptionalPositiveInt, parseRecipientsFromEnv, } from "./env-normalizers"; +import { + DEFAULT_GITHUB_COMMIT_INSTRUCTION, + DEFAULT_GITHUB_PR_INSTRUCTION, +} from "./github-instructions"; import type { InstanceServerDatabaseConfig } from "./instance-database-path"; import { normalizeOptionalPath } from "./path-resolution"; import { loadSqliteEnv } from "./sqlite-env"; @@ -63,6 +67,8 @@ export function buildEnvBase( github: { useGhCli: true, defaultBugLabel: env.GITHUB_BUG_LABEL ?? "bug", + commitInstruction: DEFAULT_GITHUB_COMMIT_INSTRUCTION, + prInstruction: DEFAULT_GITHUB_PR_INSTRUCTION, }, server: { database: { diff --git a/packages/cli/src/features/config/github-instructions.ts b/packages/cli/src/features/config/github-instructions.ts new file mode 100644 index 00000000..6a2e9deb --- /dev/null +++ b/packages/cli/src/features/config/github-instructions.ts @@ -0,0 +1,51 @@ +import type { + GithubInstructionContext, + GithubInstructionsConfig, +} from "./types/github-instructions.types"; + +export const DEFAULT_GITHUB_COMMIT_INSTRUCTION = + "[devos] {issueKey}: {issueTitle}"; + +export const DEFAULT_GITHUB_PR_INSTRUCTION = [ + "Workflow task: {issueKey}", + "", + "This PR was created by the devos.ing ADHD (Agentic Development Hub & Daemon) workflow.", + "", + "Includes:", + "- plan + implement session output", + "- separate review/testing session", +].join("\n"); + +export function normalizeGithubInstructionsConfig( + value: Record, +): GithubInstructionsConfig | undefined { + const commitInstruction = normalizeGithubInstruction(value.commitInstruction); + const prInstruction = normalizeGithubInstruction(value.prInstruction); + if (!commitInstruction && !prInstruction) { + return undefined; + } + return { + ...(commitInstruction ? { commitInstruction } : {}), + ...(prInstruction ? { prInstruction } : {}), + }; +} + +export function normalizeGithubInstruction(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +export function renderGithubInstruction( + template: string | undefined, + context: GithubInstructionContext, + defaultTemplate: string, +): string { + const source = normalizeGithubInstruction(template) ?? defaultTemplate; + return source.replace( + /\{(baseBranch|branch|issueKey|issueTitle)\}/g, + (_match, key: keyof GithubInstructionContext) => context[key], + ); +} diff --git a/packages/cli/src/features/config/index.ts b/packages/cli/src/features/config/index.ts index 0e3b6280..78098053 100644 --- a/packages/cli/src/features/config/index.ts +++ b/packages/cli/src/features/config/index.ts @@ -12,6 +12,7 @@ import { import { normalizeOptionalValue } from "./env-normalizers"; import { loadInstanceCodexConfig } from "./instance-codex"; import { loadInstanceServerDatabaseConfig } from "./instance-database-path"; +import { loadInstanceGithubConfig } from "./instance-github"; import { loadInstanceWorkspaceConfig } from "./instance-workspace"; import { resolveNotifications } from "./notification-resolution"; import { applyInstancePlugins } from "./plugin-resolution"; @@ -57,8 +58,12 @@ async function loadConfigWithOptions( const envNotifications = buildEnvNotifications(env); const workspace = await loadInstanceWorkspaceConfig(); const instanceCodex = await loadInstanceCodexConfig(); - const root = createRuntimeRootConfig(instanceCodex); - const defaultProjectRoot = createDefaultProjectRootConfig(instanceCodex); + const instanceGithub = await loadInstanceGithubConfig(); + const root = createRuntimeRootConfig(instanceCodex, instanceGithub); + const defaultProjectRoot = createDefaultProjectRootConfig( + instanceCodex, + instanceGithub, + ); const resolvedProjects = resolveProjects(cwd, envBase, root); const metadataProjects = options.applyServerProjectMetadata @@ -89,14 +94,24 @@ async function loadConfigWithOptions( function createRuntimeRootConfig( codex: DevosRootConfig["codex"], + github: DevosRootConfig["github"], ): DevosRootConfig { - return { ...(codex ? { codex } : {}), projects: [] }; + return { + ...(codex ? { codex } : {}), + ...(github ? { github } : {}), + projects: [], + }; } function createDefaultProjectRootConfig( codex: DevosRootConfig["codex"], + github: DevosRootConfig["github"], ): DevosRootConfig { - return { ...(codex ? { codex } : {}), projects: [{ id: "default" }] }; + return { + ...(codex ? { codex } : {}), + ...(github ? { github } : {}), + projects: [{ id: "default" }], + }; } export function getProjectById( diff --git a/packages/cli/src/features/config/instance-github.ts b/packages/cli/src/features/config/instance-github.ts new file mode 100644 index 00000000..9a8a5146 --- /dev/null +++ b/packages/cli/src/features/config/instance-github.ts @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; +import { normalizeGithubInstructionsConfig } from "./github-instructions"; +import { instanceConfigPath } from "./home-paths"; +import type { GithubInstructionsConfig } from "./types/github-instructions.types"; + +export async function loadInstanceGithubConfig( + readText: ( + targetPath: string, + encoding: BufferEncoding, + ) => Promise = readFile, +): Promise { + let content: string; + try { + content = await readText(instanceConfigPath(), "utf8"); + } catch { + return undefined; + } + + try { + const parsed = JSON.parse(content) as unknown; + if (!isRecord(parsed) || !isRecord(parsed.github)) return undefined; + return normalizeGithubInstructionsConfig(parsed.github); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/cli/src/features/config/types/github-instructions.types.ts b/packages/cli/src/features/config/types/github-instructions.types.ts new file mode 100644 index 00000000..f70add2e --- /dev/null +++ b/packages/cli/src/features/config/types/github-instructions.types.ts @@ -0,0 +1,11 @@ +export interface GithubInstructionsConfig { + commitInstruction?: string; + prInstruction?: string; +} + +export interface GithubInstructionContext { + baseBranch: string; + branch: string; + issueKey: string; + issueTitle: string; +} diff --git a/packages/cli/src/features/config/types/runtime.types.ts b/packages/cli/src/features/config/types/runtime.types.ts index 85d8de44..12b40b94 100644 --- a/packages/cli/src/features/config/types/runtime.types.ts +++ b/packages/cli/src/features/config/types/runtime.types.ts @@ -1,3 +1,4 @@ +import type { GithubInstructionsConfig } from "./github-instructions.types"; import type { ServerRuntimeConfig } from "./server.types"; export type CodexReasoningEffort = "low" | "medium" | "high" | "xhigh"; @@ -38,7 +39,7 @@ export interface ProjectRuntimeConfig { name: string; baseBranch: string; }; - github: { + github: GithubInstructionsConfig & { useGhCli: boolean; defaultBugLabel: string; }; diff --git a/packages/cli/src/features/onboard/types/instance-config.types.ts b/packages/cli/src/features/onboard/types/instance-config.types.ts index d622c3c6..4e21ef72 100644 --- a/packages/cli/src/features/onboard/types/instance-config.types.ts +++ b/packages/cli/src/features/onboard/types/instance-config.types.ts @@ -42,6 +42,11 @@ export interface OnboardCodexConfig { reasoningEfforts?: Partial>; } +export interface OnboardGithubConfig { + commitInstruction?: string; + prInstruction?: string; +} + export interface OnboardInstanceConfig { $meta: { version: 1; @@ -100,6 +105,7 @@ export interface OnboardInstanceConfig { }; }; codex?: OnboardCodexConfig; + github?: OnboardGithubConfig; plugins?: InstancePluginsConfig; } diff --git a/packages/cli/src/features/onboard/types/onboard.types.ts b/packages/cli/src/features/onboard/types/onboard.types.ts index 9f38467f..34226582 100644 --- a/packages/cli/src/features/onboard/types/onboard.types.ts +++ b/packages/cli/src/features/onboard/types/onboard.types.ts @@ -135,6 +135,7 @@ export interface OnboardDraftPromptDeps { export interface OnboardWizardDeps extends Partial { runCommand?: OnboardCheckDeps["runCommand"]; + write?: (chunk: string) => void; writeOnboardFiles?: (cwd: string, draft: OnboardDraft) => Promise; collectOnboardChecks?: (cwd: string) => Promise; configurePluginCredentials?: ( diff --git a/packages/cli/src/features/onboard/wizard.ts b/packages/cli/src/features/onboard/wizard.ts index 91421c31..a868f3ec 100644 --- a/packages/cli/src/features/onboard/wizard.ts +++ b/packages/cli/src/features/onboard/wizard.ts @@ -35,11 +35,12 @@ export async function runOnboardWizard( const collectChecks = deps.collectOnboardChecks ?? collectOnboardChecks; const configurePluginCredentials = deps.configurePluginCredentials ?? configureInstalledPluginCredentials; - process.stdout.write(renderOnboardCustomizationIntro()); + const write = deps.write ?? ((chunk: string) => process.stdout.write(chunk)); + write(renderOnboardCustomizationIntro()); const rtk = await safeRun(commandRunner, "rtk", ["--version"], cwd); if (rtk.code !== 0) await promptForRtkInstall(cwd, commandRunner, prompts); const gh = await safeRun(commandRunner, "gh", ["auth", "status"], cwd); - if (gh.code !== 0) process.stdout.write(renderOnboardGitHubInstallPrompt()); + if (gh.code !== 0) write(renderOnboardGitHubInstallPrompt()); const draft = await collectOnboardDraft(cwd, { prompts, @@ -47,20 +48,18 @@ export async function runOnboardWizard( }); await writeFiles(cwd, draft); await configurePluginCredentials(cwd, prompts); - process.stdout.write( + write( `${renderCliHeading("Onboarding files written:")}\n${ENV_FILE}\nInstance config: ${instanceConfigPath()}\nSecrets saved to ${sqliteEnvDbPath(cwd)}\n\n`, ); - process.stdout.write(`${renderDevosBanner()}\n`); - process.stdout.write(`devos v${getCliVersion()}\n`); - process.stdout.write(`\n${renderCliHeading("Running doctor checks...")}\n`); + write(`${renderDevosBanner()}\n`); + write(`devos v${getCliVersion()}\n`); + write(`\n${renderCliHeading("Running doctor checks...")}\n`); const checks = await collectChecks(cwd); - process.stdout.write(formatOnboardChecks(checks)); + write(formatOnboardChecks(checks)); if (checks.some((check) => check.status === "fail")) { throw new Error("Onboard check failed"); } - process.stdout.write( - `${renderCliOutlineBox("Next command", "devos daemon")}\n`, - ); + write(`${renderCliOutlineBox("Next command", "devos daemon")}\n`); } async function promptForRtkInstall( diff --git a/packages/cli/src/integrations/github/github.ts b/packages/cli/src/integrations/github/github.ts index 1cc94f56..e90ac214 100644 --- a/packages/cli/src/integrations/github/github.ts +++ b/packages/cli/src/integrations/github/github.ts @@ -1,4 +1,9 @@ import { access } from "node:fs/promises"; +import { + DEFAULT_GITHUB_COMMIT_INSTRUCTION, + DEFAULT_GITHUB_PR_INSTRUCTION, + renderGithubInstruction, +} from "../../features/config/github-instructions"; import type { BugRecord, PullRequestRef, @@ -160,7 +165,17 @@ export async function createDraftPrFromWorktree( ); } - const commitTitle = `[devos] ${issueKey}: ${issueTitle}`; + const instructionContext = { + baseBranch: config.repo.baseBranch, + branch, + issueKey, + issueTitle, + }; + const commitTitle = renderGithubInstruction( + config.github.commitInstruction, + instructionContext, + DEFAULT_GITHUB_COMMIT_INSTRUCTION, + ); await commitChanges(config, commitTitle, { runCommand: commandRunner, assertCommandOk: assertOk, @@ -175,15 +190,11 @@ export async function createDraftPrFromWorktree( assertCommandOk: assertOk, }); const prTitle = `[codex] ${issueKey}: ${issueTitle}`; - const prBody = [ - `Workflow task: ${issueKey}`, - "", - "This PR was created by the devos.ing ADHD (Agentic Development Hub & Daemon) workflow.", - "", - "Includes:", - "- plan + implement session output", - "- separate review/testing session", - ].join("\n"); + const prBody = renderGithubInstruction( + config.github.prInstruction, + instructionContext, + DEFAULT_GITHUB_PR_INSTRUCTION, + ); const create = await withRetries("gh pr create", async () => { const result = await commandRunner( @@ -245,7 +256,16 @@ export async function updateDraftPrFromWorktree( return false; } - const commitTitle = `[devos] ${issueKey}: address review feedback`; + const commitTitle = renderGithubInstruction( + config.github.commitInstruction, + { + baseBranch: config.repo.baseBranch, + branch: prBranch, + issueKey, + issueTitle: "address review feedback", + }, + DEFAULT_GITHUB_COMMIT_INSTRUCTION, + ); await commitChanges(config, commitTitle, { runCommand: commandRunner, assertCommandOk: assertOk, diff --git a/packages/cli/tests/config.test.ts b/packages/cli/tests/config.test.ts index 30be499a..bec0095b 100644 --- a/packages/cli/tests/config.test.ts +++ b/packages/cli/tests/config.test.ts @@ -436,6 +436,36 @@ describe("loadConfig", () => { } }); + it("loads GitHub workflow instructions from instance config", async () => { + const tempDir = await mkdtemp( + path.join(process.cwd(), ".tmp-config-test-"), + ); + const instanceConfig = { + ...createInstanceConfig(tempDir, "2026-05-29T00:00:00.000Z"), + github: { + commitInstruction: "commit {issueKey}: {issueTitle}", + prInstruction: "PR for {issueKey} from {branch}", + }, + }; + await mkdir(path.dirname(instanceConfigPath()), { recursive: true }); + await writeFile( + instanceConfigPath(), + renderInstanceConfigDocument(instanceConfig), + ); + + try { + const config = await loadConfig(tempDir); + expect(config.projects[0]?.github.commitInstruction).toBe( + "commit {issueKey}: {issueTitle}", + ); + expect(config.projects[0]?.github.prInstruction).toBe( + "PR for {issueKey} from {branch}", + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it("prefers process env over sqlite values", async () => { const tempDir = await mkdtemp( path.join(process.cwd(), ".tmp-config-test-"), diff --git a/packages/cli/tests/github.test.ts b/packages/cli/tests/github.test.ts index 737c49d3..7b68fe44 100644 --- a/packages/cli/tests/github.test.ts +++ b/packages/cli/tests/github.test.ts @@ -13,6 +13,7 @@ import { prepareWorktreeDependencies, removeIssueWorktree, squashMergePullRequest, + updateDraftPrFromWorktree, } from "../src/integrations/github"; import type { CommandResult } from "../src/utils/shell"; @@ -500,6 +501,107 @@ describe("createDraftPrFromWorktree", () => { "OWN-1", ]); }); + + it("uses configured commit and PR instructions for draft PR creation", async () => { + const calls: string[][] = []; + const config = createProjectConfig(); + config.github.commitInstruction = + "ship {issueKey} on {branch}: {issueTitle}"; + config.github.prInstruction = + "Task {issueKey}\nTitle {issueTitle}\nBranch {branch}\nBase {baseBranch}"; + const runCommand = mock( + async (_command: string, args: string[]): Promise => { + calls.push(args); + if (args[0] === "rev-parse") { + return { code: 0, stdout: "true\n", stderr: "" }; + } + if (args[0] === "branch" && args[1] === "--show-current") { + return { code: 0, stdout: "codex/eng-42\n", stderr: "" }; + } + if (args[0] === "diff") { + return { code: 1, stdout: "", stderr: "" }; + } + if ( + args[0] === "add" || + args[0] === "commit" || + args[0] === "push" || + args[0] === "auth" + ) { + return { code: 0, stdout: "", stderr: "" }; + } + if (args[0] === "pr" && args[1] === "create") { + return { + code: 0, + stdout: "https://github.com/acme/repo/pull/101\n", + stderr: "", + }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + ); + + await createDraftPrFromWorktree(config, "ENG-42", "Custom text", { + runCommand, + assertCommandOk: assertOk, + }); + + expect(calls).toContainEqual([ + "commit", + "-m", + "ship ENG-42 on codex/eng-42: Custom text", + ]); + expect(calls).toContainEqual([ + "pr", + "create", + "--draft", + "--title", + "[codex] ENG-42: Custom text", + "--body", + "Task ENG-42\nTitle Custom text\nBranch codex/eng-42\nBase main", + "--base", + "main", + "--head", + "codex/eng-42", + ]); + }); +}); + +describe("updateDraftPrFromWorktree", () => { + it("uses configured commit instruction for review updates", async () => { + const calls: string[][] = []; + const config = createProjectConfig(); + config.github.commitInstruction = + "ship {issueKey} on {branch}: {issueTitle}"; + const runCommand = mock( + async (_command: string, args: string[]): Promise => { + calls.push(args); + if (args[0] === "rev-parse") { + return { code: 0, stdout: "true\n", stderr: "" }; + } + if (args[0] === "branch" && args[1] === "--show-current") { + return { code: 0, stdout: "codex/eng-42\n", stderr: "" }; + } + if (args[0] === "diff") { + return { code: 1, stdout: "", stderr: "" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + ); + + const updated = await updateDraftPrFromWorktree( + config, + "codex/eng-42", + "ENG-42", + { runCommand, assertCommandOk: assertOk }, + ); + + expect(updated).toBe(true); + expect(calls).toContainEqual([ + "commit", + "-m", + "ship ENG-42 on codex/eng-42: address review feedback", + ]); + }); }); describe("ensureIssueWorktree", () => { diff --git a/packages/cli/tests/onboard-next-command.test.ts b/packages/cli/tests/onboard-next-command.test.ts index 4b050778..1ee7c6dd 100644 --- a/packages/cli/tests/onboard-next-command.test.ts +++ b/packages/cli/tests/onboard-next-command.test.ts @@ -17,16 +17,18 @@ describe("onboard next command", () => { it("prints devos daemon after successful onboard checks", async () => { const tempDir = await createTempHome(); try { - const output = await captureStdout(() => - runOnboardWizard(tempDir, { - runCommand: async () => okCommand(), - prompts: onboardingPromptAdapter(), - collectOnboardChecks: async (): Promise => [ - { name: "Instance config", status: "pass", message: "ok" }, - ], - configurePluginCredentials: async () => {}, - }), - ); + let output = ""; + await runOnboardWizard(tempDir, { + runCommand: async () => okCommand(), + prompts: onboardingPromptAdapter(), + collectOnboardChecks: async (): Promise => [ + { name: "Instance config", status: "pass", message: "ok" }, + ], + configurePluginCredentials: async () => {}, + write: (chunk) => { + output += chunk; + }, + }); const successIndex = output.indexOf("All checks passed!"); const daemonIndex = output.indexOf("devos daemon"); @@ -52,16 +54,18 @@ describe("onboard next command", () => { it("keeps the onboarding description text in the intro output", async () => { const tempDir = await createTempHome(); try { - const output = await captureStdout(() => - runOnboardWizard(tempDir, { - runCommand: async () => okCommand(), - prompts: onboardingPromptAdapter(), - collectOnboardChecks: async (): Promise => [ - { name: "Instance config", status: "pass", message: "ok" }, - ], - configurePluginCredentials: async () => {}, - }), - ); + let output = ""; + await runOnboardWizard(tempDir, { + runCommand: async () => okCommand(), + prompts: onboardingPromptAdapter(), + collectOnboardChecks: async (): Promise => [ + { name: "Instance config", status: "pass", message: "ok" }, + ], + configurePluginCredentials: async () => {}, + write: (chunk) => { + output += chunk; + }, + }); const introLine = "devos onboard will configure:"; expect(output).toContain(introLine); @@ -78,21 +82,6 @@ async function createTempHome(): Promise { return tempDir; } -async function captureStdout(run: () => Promise): Promise { - const previousWrite = process.stdout.write; - let output = ""; - process.stdout.write = ((chunk: string | Uint8Array) => { - output += String(chunk); - return true; - }) as typeof process.stdout.write; - try { - await run(); - return output; - } finally { - process.stdout.write = previousWrite; - } -} - function onboardingPromptAdapter(): PromptAdapter { return { text: async ({ defaultValue }) => defaultValue ?? "", diff --git a/packages/server/src/http/settings-routes.ts b/packages/server/src/http/settings-routes.ts index 8f6e2a47..b223bfde 100644 --- a/packages/server/src/http/settings-routes.ts +++ b/packages/server/src/http/settings-routes.ts @@ -1,3 +1,8 @@ +import { + SettingsGithubError, + getSettingsGithub, + updateSettingsGithub, +} from "../settings/settings-github-service"; import { SettingsModelsError, getSettingsModels, @@ -6,16 +11,31 @@ import { import { jsonError, jsonSuccess, methodNotAllowedResponse } from "./response"; const SETTINGS_MODELS_PATH = "/api/settings/models"; +const SETTINGS_GITHUB_PATH = "/api/settings/github"; export async function handleSettingsRoute( request: Request, pathname: string, workspacePath: string, ): Promise { - if (pathname !== SETTINGS_MODELS_PATH) { + if (pathname !== SETTINGS_MODELS_PATH && pathname !== SETTINGS_GITHUB_PATH) { return null; } try { + if (pathname === SETTINGS_GITHUB_PATH) { + if (request.method === "GET") { + return jsonSuccess(await getSettingsGithub(workspacePath)); + } + if (request.method === "PATCH") { + return jsonSuccess( + await updateSettingsGithub( + workspacePath, + await parseJsonBody(request), + ), + ); + } + return methodNotAllowedResponse(); + } if (request.method === "GET") { return jsonSuccess(await getSettingsModels(workspacePath)); } @@ -26,6 +46,9 @@ export async function handleSettingsRoute( } return methodNotAllowedResponse(); } catch (error) { + if (error instanceof SettingsGithubError) { + return jsonError(error.message, { status: error.status }); + } if (error instanceof SettingsModelsError) { return jsonError(error.message, { status: error.status }); } diff --git a/packages/server/src/settings/settings-github-service.ts b/packages/server/src/settings/settings-github-service.ts new file mode 100644 index 00000000..a0ced53a --- /dev/null +++ b/packages/server/src/settings/settings-github-service.ts @@ -0,0 +1,90 @@ +import { + DEFAULT_GITHUB_COMMIT_INSTRUCTION, + DEFAULT_GITHUB_PR_INSTRUCTION, + normalizeGithubInstruction, +} from "devos/features/config/github-instructions"; +import { + loadInstanceConfig, + saveInstanceConfig, +} from "devos/features/onboard/instance-config"; +import type { OnboardInstanceConfig } from "devos/features/onboard/types/instance-config.types"; +import type { + SettingsGithubResponse, + SettingsGithubUpdateRequest, +} from "./types/settings-github.types"; + +export class SettingsGithubError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "SettingsGithubError"; + } +} + +export async function getSettingsGithub( + cwd: string, +): Promise { + const config = await readInstanceConfig(cwd); + return renderSettingsGithub(config); +} + +export async function updateSettingsGithub( + cwd: string, + request: unknown, +): Promise { + validateUpdateRequest(request); + const config = await readInstanceConfig(cwd); + config.github = { + commitInstruction: + normalizeGithubInstruction(request.commitInstruction) ?? + DEFAULT_GITHUB_COMMIT_INSTRUCTION, + prInstruction: + normalizeGithubInstruction(request.prInstruction) ?? + DEFAULT_GITHUB_PR_INSTRUCTION, + }; + await saveInstanceConfig(config); + return renderSettingsGithub(config); +} + +function renderSettingsGithub( + config: OnboardInstanceConfig, +): SettingsGithubResponse { + return { + commitInstruction: + normalizeGithubInstruction(config.github?.commitInstruction) ?? + DEFAULT_GITHUB_COMMIT_INSTRUCTION, + prInstruction: + normalizeGithubInstruction(config.github?.prInstruction) ?? + DEFAULT_GITHUB_PR_INSTRUCTION, + }; +} + +async function readInstanceConfig(cwd: string): Promise { + const result = await loadInstanceConfig(cwd); + if (!result.ok) { + throw new SettingsGithubError(404, result.message); + } + return result.config; +} + +function validateUpdateRequest( + request: unknown, +): asserts request is SettingsGithubUpdateRequest { + if (!isRecord(request)) { + throw new SettingsGithubError(400, "settings update must be an object"); + } + validateInstruction(request.commitInstruction, "commitInstruction"); + validateInstruction(request.prInstruction, "prInstruction"); +} + +function validateInstruction(value: unknown, field: string): void { + if (value !== undefined && typeof value !== "string") { + throw new SettingsGithubError(400, `settings ${field} must be a string`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/server/src/settings/types/settings-github.types.ts b/packages/server/src/settings/types/settings-github.types.ts new file mode 100644 index 00000000..d063f0ad --- /dev/null +++ b/packages/server/src/settings/types/settings-github.types.ts @@ -0,0 +1,9 @@ +export interface SettingsGithubResponse { + commitInstruction: string; + prInstruction: string; +} + +export interface SettingsGithubUpdateRequest { + commitInstruction?: string; + prInstruction?: string; +} diff --git a/packages/server/tests/openapi-contract.test.ts b/packages/server/tests/openapi-contract.test.ts index 4988a938..67dddd44 100644 --- a/packages/server/tests/openapi-contract.test.ts +++ b/packages/server/tests/openapi-contract.test.ts @@ -51,6 +51,8 @@ const IMPLEMENTED_ROUTES = [ ["POST", "/api/notifications"], ["POST", "/api/notifications/email"], ["GET", "/api/polling/status"], + ["GET", "/api/settings/github"], + ["PATCH", "/api/settings/github"], ["GET", "/api/settings/models"], ["PATCH", "/api/settings/models"], ] as const; diff --git a/packages/server/tests/settings-models-routes.test.ts b/packages/server/tests/settings-models-routes.test.ts index 62e227e7..a5f9d3c8 100644 --- a/packages/server/tests/settings-models-routes.test.ts +++ b/packages/server/tests/settings-models-routes.test.ts @@ -97,6 +97,43 @@ describe("settings model routes", () => { expect(loaded.config.codex?.reasoningEfforts?.brainstorm).toBe("xhigh"); expect(loaded.config.codex?.reasoningEfforts?.reviewTest).toBe("medium"); }); + + it("reads and updates persisted GitHub workflow instructions", async () => { + await writeInstanceConfig({ + github: { + commitInstruction: "commit {issueKey}: {issueTitle}", + prInstruction: "PR for {issueKey} on {branch}", + }, + }); + + const readResponse = await createApp()( + new Request("http://localhost/api/settings/github"), + ); + + expect(readResponse.status).toBe(200); + await expect(readResponse.json()).resolves.toEqual({ + commitInstruction: "commit {issueKey}: {issueTitle}", + prInstruction: "PR for {issueKey} on {branch}", + }); + + const updateResponse = await createApp()( + jsonRequest("PATCH", "/api/settings/github", { + commitInstruction: "ship {issueKey}: {issueTitle}", + prInstruction: "Open PR from {branch} into {baseBranch}", + }), + ); + + expect(updateResponse.status).toBe(200); + const loaded = await loadInstanceConfig("/tmp/project"); + expect(loaded.ok).toBe(true); + if (!loaded.ok) return; + expect(loaded.config.github?.commitInstruction).toBe( + "ship {issueKey}: {issueTitle}", + ); + expect(loaded.config.github?.prInstruction).toBe( + "Open PR from {branch} into {baseBranch}", + ); + }); }); async function writeInstanceConfig( diff --git a/packages/web/src/app/(operator)/settings/git/page.tsx b/packages/web/src/app/(operator)/settings/git/page.tsx new file mode 100644 index 00000000..badeb9b9 --- /dev/null +++ b/packages/web/src/app/(operator)/settings/git/page.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from "react"; + +import { OperatorSectionPanel } from "@/components/web-shell/operator-section-panel"; + +export default function GitSettingsPage(): ReactElement { + return ; +} diff --git a/packages/web/src/components/chat-room/chat-room-settings-sidebar.tsx b/packages/web/src/components/chat-room/chat-room-settings-sidebar.tsx index 0e216523..87cb1144 100644 --- a/packages/web/src/components/chat-room/chat-room-settings-sidebar.tsx +++ b/packages/web/src/components/chat-room/chat-room-settings-sidebar.tsx @@ -8,11 +8,13 @@ import { Bot, ChartColumn, Computer, + GitBranch, Inbox, ListChecks, MessageSquare, Plug, Settings, + SlidersHorizontal, Sparkles, SquareKanban, UsersRound, @@ -22,6 +24,11 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import type { ComponentType, ReactElement } from "react"; +import { + isSettingsNavItemActive, + settingsNavItems, +} from "@/components/settings/settings-navigation"; +import type { SettingsView } from "@/components/settings/types/settings-navigation.types"; import { Button } from "@/components/ui/button"; import { Typography } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; @@ -45,6 +52,14 @@ const iconByKey: Record< usage: ChartColumn, }; +const settingsIconByKey: Record< + SettingsView, + ComponentType<{ size?: number }> +> = { + git: GitBranch, + models: SlidersHorizontal, +}; + export function ChatRoomSettingsSidebar({ isActive, onBack, @@ -90,6 +105,34 @@ export function ChatRoomSettingsSidebar({