From 40e903d8339c3fb741cdbbcd66c04301c29febd1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 16 May 2026 22:56:42 -0700 Subject: [PATCH] fix: address review findings (#2138) --- src/main/ipc/filesystem.ts | 98 +++++- src/main/ipc/hosted-review.ts | 24 +- src/main/runtime/orca-runtime-git.ts | 82 ++++- src/main/runtime/orca-runtime.test.ts | 99 +++++- src/main/runtime/orca-runtime.ts | 35 +- src/main/runtime/rpc/methods/git-params.ts | 114 +++++++ src/main/runtime/rpc/methods/git.ts | 162 ++++------ .../runtime/rpc/methods/hosted-review.test.ts | 4 + src/main/runtime/rpc/methods/hosted-review.ts | 4 + .../commit-message-text-generation.test.ts | 91 ++++++ .../commit-message-text-generation.ts | 118 +++++-- .../text-generation/pull-request-context.ts | 64 ++++ src/preload/api-types.ts | 19 ++ src/preload/index.ts | 12 + .../components/right-sidebar/ChecksPanel.tsx | 5 + .../right-sidebar/CreatePullRequestDialog.tsx | 185 ++++++----- .../right-sidebar/SourceControl.tsx | 18 +- .../source-control-dropdown-items.test.ts | 15 + .../source-control-dropdown-items.ts | 22 +- .../source-control-primary-action.test.ts | 14 +- .../source-control-primary-action.ts | 16 +- .../useCreatePullRequestDialogFields.ts | 299 ++++++++++++++++++ .../src/runtime/runtime-git-client.ts | 51 +++ .../store/slices/hosted-review-cache.test.ts | 171 ++++++++++ .../src/store/slices/hosted-review.test.ts | 227 ++++++------- .../src/store/slices/hosted-review.ts | 15 +- src/renderer/src/web/web-preload-api.ts | 5 + src/shared/hosted-review.ts | 2 + src/shared/pull-request-generation.test.ts | 56 ++++ src/shared/pull-request-generation.ts | 114 +++++++ 30 files changed, 1775 insertions(+), 366 deletions(-) create mode 100644 src/main/runtime/rpc/methods/git-params.ts create mode 100644 src/main/text-generation/pull-request-context.ts create mode 100644 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts create mode 100644 src/renderer/src/store/slices/hosted-review-cache.test.ts create mode 100644 src/shared/pull-request-generation.test.ts create mode 100644 src/shared/pull-request-generation.ts diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index ed896826d..d96f9a733 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -3,7 +3,7 @@ import { ipcMain, shell } from 'electron' import { readdir, readFile, writeFile, stat, lstat, open } from 'fs/promises' import { extname, join } from 'path' import type { ChildProcess } from 'child_process' -import { wslAwareSpawn } from '../git/runner' +import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import type { Store } from '../persistence' import type { @@ -48,10 +48,14 @@ import { import { getHistory } from '../git/history' import { cancelGenerateCommitMessageLocal, + cancelGeneratePullRequestFieldsLocal, generateCommitMessageFromContext, + generatePullRequestFieldsFromContext, resolveCommitMessageSettings, - type GenerateCommitMessageResult + type GenerateCommitMessageResult, + type GeneratePullRequestFieldsResult } from '../text-generation/commit-message-text-generation' +import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { getUpstreamStatus } from '../git/upstream' import { gitFetch, gitPull, gitPush } from '../git/remote' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' @@ -691,6 +695,96 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:generatePullRequestFields', + async ( + _event, + args: { + worktreePath: string + base: string + title: string + body: string + draft: boolean + connectionId?: string + } + ): Promise => { + const resolvedSettings = resolveCommitMessageSettings(store.getSettings()) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: `No git provider for connection "${args.connectionId}"` + } + } + const context = await getPullRequestDraftContext( + (argv) => provider.exec(argv, args.worktreePath), + { + base: args.base, + currentTitle: args.title, + currentBody: args.body, + currentDraft: args.draft + } + ) + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + missingBinaryLocation: 'remote PATH' + }) + } + + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const context = await getPullRequestDraftContext( + (argv, options) => gitExecFileAsync(argv, { cwd: worktreePath, ...options }), + { + base: args.base, + currentTitle: args.title, + currentBody: args.body, + currentDraft: args.draft + } + ) + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'local', + cwd: worktreePath, + ...(localEnv.env ? { env: localEnv.env } : {}) + }) + } + ) + + ipcMain.handle( + 'git:cancelGeneratePullRequestFields', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath) + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGeneratePullRequestFieldsLocal(worktreePath) + } + ) + ipcMain.handle( 'git:branchCompare', async ( diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 909eec869..d972334bf 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -13,6 +13,8 @@ import { getHostedReviewCreationEligibility } from '../source-control/hosted-review-creation' import { getHostedReviewForBranch } from '../source-control/hosted-review' +import { resolveRegisteredWorktreePath } from './filesystem-auth' +import { listRepoWorktrees } from '../repo-worktrees' function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): Repo { if (repoId) { @@ -30,6 +32,22 @@ function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): return repo } +async function resolveHostedReviewWorktreePath( + repo: Repo, + store: Store, + worktreePath?: string +): Promise { + if (!worktreePath) { + return repo.path + } + const resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) + const repoWorktrees = await listRepoWorktrees(repo) + if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) { + throw new Error('Access denied: worktree does not belong to repository') + } + return resolvedWorktreePath +} + export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void { ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => { const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) @@ -71,7 +89,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector body: null } } - return getHostedReviewCreationEligibility({ ...args, repoPath: repo.path }) + const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) + return getHostedReviewCreationEligibility({ ...args, repoPath: worktreePath }) } ) @@ -84,7 +103,8 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector error: 'Creating pull requests from SSH worktrees is not supported yet.' } } - const result = await createHostedReview(repo.path, { + const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) + const result = await createHostedReview(worktreePath, { provider: args.provider, base: args.base, head: args.head, diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index f5fa5ada3..46c1f30e9 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -37,13 +37,18 @@ import { gitFetch, gitPull, gitPush } from '../git/remote' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { cancelGenerateCommitMessageLocal, + cancelGeneratePullRequestFieldsLocal, generateCommitMessageFromContext, + generatePullRequestFieldsFromContext, resolveCommitMessageSettings, - type GenerateCommitMessageResult + type GenerateCommitMessageResult, + type GeneratePullRequestFieldsResult } from '../text-generation/commit-message-text-generation' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment' +import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { gitExecFileAsync } from '../git/runner' export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo } type RuntimeCommitMessageSettingsOverride = Partial< @@ -379,6 +384,81 @@ export class RuntimeGitCommands { return { ok: true } } + async generateRuntimePullRequestFields( + worktreeSelector: string, + input: { base: string; title: string; body: string; draft: boolean }, + settingsOverride?: RuntimeCommitMessageSettingsOverride + ): Promise { + const resolvedSettings = resolveCommitMessageSettings({ + ...this.host.getRuntimeSettings(), + ...settingsOverride + }) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId && !provider) { + return { + success: false, + error: `No git provider for connection "${target.connectionId}"` + } + } + const context = target.connectionId + ? await getPullRequestDraftContext((argv) => provider!.exec(argv, target.worktree.path), { + base: input.base, + currentTitle: input.title, + currentBody: input.body, + currentDraft: input.draft + }) + : await getPullRequestDraftContext( + (argv, options) => gitExecFileAsync(argv, { cwd: target.worktree.path, ...options }), + { + base: input.base, + currentTitle: input.title, + currentBody: input.body, + currentDraft: input.draft + } + ) + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + + if (target.connectionId) { + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: target.worktree.path, + execute: (plan, cwd, timeoutMs) => provider!.executeCommitMessagePlan(plan, cwd, timeoutMs), + missingBinaryLocation: 'remote PATH' + }) + } + + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + this.host.getCommitMessageAgentEnvironment?.() + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'local', + cwd: target.worktree.path, + ...(localEnv.env ? { env: localEnv.env } : {}) + }) + } + + async cancelRuntimeGeneratePullRequestFields(worktreeSelector: string): Promise<{ ok: true }> { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + await provider?.cancelGenerateCommitMessage(target.worktree.path) + return { ok: true } + } + cancelGeneratePullRequestFieldsLocal(target.worktree.path) + return { ok: true } + } + async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const relativePath = normalizeRuntimeGitRelativePath(filePath) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cae344efb..a34ef8c00 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -38,7 +38,9 @@ const { getSshGitProviderMock, registerSshGitProviderMock, unregisterSshGitProviderMock, - invalidateAuthorizedRootsCacheMock + invalidateAuthorizedRootsCacheMock, + createHostedReviewMock, + getHostedReviewCreationEligibilityMock } = vi.hoisted(() => { // Why: SSH runtime tests register providers through the public dispatcher API, // so the mock needs the same registry semantics as the real module. @@ -66,7 +68,9 @@ const { unregisterSshGitProviderMock: vi.fn((connectionId: string) => { sshGitProviders.delete(connectionId) }), - invalidateAuthorizedRootsCacheMock: vi.fn() + invalidateAuthorizedRootsCacheMock: vi.fn(), + createHostedReviewMock: vi.fn(), + getHostedReviewCreationEligibilityMock: vi.fn() } }) @@ -110,6 +114,11 @@ vi.mock('../ipc/filesystem-auth', () => ({ Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') })) +vi.mock('../source-control/hosted-review-creation', () => ({ + createHostedReview: createHostedReviewMock, + getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock +})) + // Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a // fallback base branch. Real resolution shells out to `git` against the // test's fabricated repo path, which has no refs, so we stub it to a @@ -157,6 +166,25 @@ afterEach(() => { computeWorktreePathMock.mockReset() ensurePathWithinWorkspaceMock.mockReset() invalidateAuthorizedRootsCacheMock.mockReset() + createHostedReviewMock.mockReset() + createHostedReviewMock.mockResolvedValue({ + ok: true, + provider: 'github', + number: 1, + url: 'https://example.com/pull/1' + }) + getHostedReviewCreationEligibilityMock.mockReset() + getHostedReviewCreationEligibilityMock.mockResolvedValue({ + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: 'main', + head: 'feature/foo', + title: null, + body: null + }) }) function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void { @@ -810,6 +838,73 @@ describe('OrcaRuntimeService', () => { ) }) + it('rejects hosted review worktree selectors outside the selected repo', async () => { + vi.mocked(listWorktrees).mockImplementation(async (repoPath: string) => { + if (repoPath === '/tmp/repo-b') { + return [ + { + path: '/tmp/worktree-b', + head: 'def', + branch: 'feature/bar', + isBare: false, + isMainWorktree: false + } + ] + } + return MOCK_GIT_WORKTREES + }) + const repos = [ + { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 + }, + { + id: 'repo-2', + path: '/tmp/repo-b', + displayName: 'repo-b', + badgeColor: 'green', + addedAt: 2 + } + ] + const multiRepoStore = { + ...store, + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id) + } + const runtime = new OrcaRuntimeService(multiRepoStore as never) + + await expect( + runtime.getHostedReviewCreationEligibility({ + repoSelector: 'id:repo-1', + worktreeSelector: 'id:repo-2::/tmp/worktree-b', + branch: 'feature/bar', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 1, + behind: 0 + }) + ).rejects.toThrow('Access denied: worktree does not belong to repository') + await expect( + runtime.createHostedReview({ + repoSelector: 'id:repo-1', + worktreeSelector: 'id:repo-2::/tmp/worktree-b', + provider: 'github', + base: 'main', + head: 'feature/bar', + title: 'Create PR', + body: '', + draft: false + }) + ).rejects.toThrow('Access denied: worktree does not belong to repository') + + expect(getHostedReviewCreationEligibilityMock).not.toHaveBeenCalled() + expect(createHostedReviewMock).not.toHaveBeenCalled() + }) + it('treats SSH worktree drift as unknown without local git probes', async () => { vi.mocked(listWorktrees).mockClear() vi.mocked(getDefaultBaseRef).mockClear() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 660cd4832..632bfc1ba 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1239,6 +1239,10 @@ export class OrcaRuntimeService { this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands) cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] = this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands) + generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] = + this.gitCommands.generateRuntimePullRequestFields.bind(this.gitCommands) + cancelRuntimeGeneratePullRequestFields: RuntimeGitCommands['cancelRuntimeGeneratePullRequestFields'] = + this.gitCommands.cancelRuntimeGeneratePullRequestFields.bind(this.gitCommands) stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] = this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands) unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] = @@ -4467,6 +4471,22 @@ export class OrcaRuntimeService { } } + private async resolveHostedReviewTarget(args: { + repoSelector: string + worktreeSelector?: string + }): Promise<{ repo: Repo; repoPath: string }> { + const repo = await this.resolveRepoSelector(args.repoSelector) + if (!args.worktreeSelector) { + return { repo, repoPath: repo.path } + } + + const worktree = await this.resolveWorktreeSelector(args.worktreeSelector) + if (worktree.repoId !== repo.id) { + throw new Error('Access denied: worktree does not belong to repository') + } + return { repo, repoPath: worktree.path } + } + async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { const repo = await this.resolveRepoSelector(repoSelector) this.assertHostIntegrationRepoIsLocal(repo, 'repo_slug') @@ -4583,12 +4603,15 @@ export class OrcaRuntimeService { } async getHostedReviewCreationEligibility( - args: Omit & { repoSelector: string } + args: Omit & { + repoSelector: string + worktreeSelector?: string + } ): Promise { - const repo = await this.resolveRepoSelector(args.repoSelector) + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') return getHostedReviewCreationEligibilityFromRepo({ - repoPath: repo.path, + repoPath, branch: args.branch, base: args.base ?? null, hasUncommittedChanges: args.hasUncommittedChanges, @@ -4604,11 +4627,11 @@ export class OrcaRuntimeService { } async createHostedReview( - args: CreateHostedReviewInput & { repoSelector: string } + args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string } ): Promise { - const repo = await this.resolveRepoSelector(args.repoSelector) + const { repo, repoPath } = await this.resolveHostedReviewTarget(args) this.assertHostIntegrationRepoIsLocal(repo, 'hosted_review') - const result = await createHostedReviewFromRepo(repo.path, { + const result = await createHostedReviewFromRepo(repoPath, { provider: args.provider, base: args.base, head: args.head, diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts new file mode 100644 index 000000000..69b2c21f5 --- /dev/null +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -0,0 +1,114 @@ +import { z } from 'zod' + +export const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +export const GitStatusParams = WorktreeSelector.extend({ + includeIgnored: z.boolean().optional() +}) + +export const GitFilePath = WorktreeSelector.extend({ + filePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing file path')) +}) + +export const GitDiff = GitFilePath.extend({ + staged: z.boolean(), + compareAgainstHead: z.boolean().optional() +}) + +export const GitBranchCompare = WorktreeSelector.extend({ + baseRef: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe( + z + .string() + .min(1, 'Missing base ref') + .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') + ) +}) + +const FullGitObjectId = z + .string() + .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') + +export const GitCommitCompare = WorktreeSelector.extend({ + commitId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(FullGitObjectId) +}) + +export const GitHistory = WorktreeSelector.extend({ + limit: z.number().int().min(1).max(200).optional(), + baseRef: z.string().nullable().optional() +}) + +export const GitBranchDiff = GitFilePath.extend({ + compare: z.object({ + baseRef: z.string().optional(), + baseOid: FullGitObjectId.optional(), + headOid: FullGitObjectId, + mergeBase: FullGitObjectId + }), + oldPath: z.string().optional() +}) + +export const GitCommitDiff = GitFilePath.extend({ + commitOid: FullGitObjectId, + parentOid: FullGitObjectId.nullable().optional(), + oldPath: z.string().optional() +}) + +export const GitCommit = WorktreeSelector.extend({ + message: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing commit message')) +}) + +const CommitMessageAiSettings = z.object({ + enabled: z.boolean(), + agentId: z.string().nullable(), + selectedModelByAgent: z.record(z.string(), z.string()), + selectedThinkingByModel: z.record(z.string(), z.string()), + customPrompt: z.string(), + customAgentCommand: z.string() +}) + +export const GitGenerateCommitMessage = WorktreeSelector.extend({ + commitMessageAi: CommitMessageAiSettings.optional(), + agentCmdOverrides: z.record(z.string(), z.string()).optional(), + enableGitHubAttribution: z.boolean().optional() +}) + +export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({ + base: z.string().min(1, 'Missing base branch'), + title: z.string(), + body: z.string(), + draft: z.boolean() +}) + +export const GitBulkPaths = WorktreeSelector.extend({ + filePaths: z.array(z.string().min(1, 'Missing file path')) +}) + +export const GitPush = WorktreeSelector.extend({ + publish: z.boolean().optional(), + pushTarget: z.unknown().optional() +}) + +export const GitRemoteFileUrl = WorktreeSelector.extend({ + relativePath: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing relative path')), + line: z.number().int().min(1) +}) diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index c6db872eb..2eda43d67 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -1,112 +1,22 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import type { GlobalSettings } from '../../../../shared/types' - -const WorktreeSelector = z.object({ - worktree: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree selector')) -}) - -const GitStatusParams = WorktreeSelector.extend({ - includeIgnored: z.boolean().optional() -}) - -const GitFilePath = WorktreeSelector.extend({ - filePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing file path')) -}) - -const GitDiff = GitFilePath.extend({ - staged: z.boolean(), - compareAgainstHead: z.boolean().optional() -}) - -const GitBranchCompare = WorktreeSelector.extend({ - baseRef: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe( - z - .string() - .min(1, 'Missing base ref') - .refine((value) => !value.startsWith('-'), 'Base ref must not start with -') - ) -}) - -const FullGitObjectId = z - .string() - .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') - -const GitCommitCompare = WorktreeSelector.extend({ - commitId: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(FullGitObjectId) -}) - -const GitHistory = WorktreeSelector.extend({ - limit: z.number().int().min(1).max(200).optional(), - baseRef: z.string().nullable().optional() -}) - -const GitBranchDiff = GitFilePath.extend({ - compare: z.object({ - baseRef: z.string().optional(), - baseOid: FullGitObjectId.optional(), - headOid: FullGitObjectId, - mergeBase: FullGitObjectId - }), - oldPath: z.string().optional() -}) - -const GitCommitDiff = GitFilePath.extend({ - commitOid: FullGitObjectId, - parentOid: FullGitObjectId.nullable().optional(), - oldPath: z.string().optional() -}) - -const GitCommit = WorktreeSelector.extend({ - message: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing commit message')) -}) - -const CommitMessageAiSettings = z.object({ - enabled: z.boolean(), - agentId: z.string().nullable(), - selectedModelByAgent: z.record(z.string(), z.string()), - selectedThinkingByModel: z.record(z.string(), z.string()), - customPrompt: z.string(), - customAgentCommand: z.string() -}) - -const GitGenerateCommitMessage = WorktreeSelector.extend({ - commitMessageAi: CommitMessageAiSettings.optional(), - agentCmdOverrides: z.record(z.string(), z.string()).optional(), - enableGitHubAttribution: z.boolean().optional() -}) - -const GitBulkPaths = WorktreeSelector.extend({ - filePaths: z.array(z.string().min(1, 'Missing file path')) -}) - -const GitPush = WorktreeSelector.extend({ - publish: z.boolean().optional(), - pushTarget: z.unknown().optional() -}) - -const GitRemoteFileUrl = WorktreeSelector.extend({ - relativePath: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing relative path')), - line: z.number().int().min(1) -}) +import { + GitBranchCompare, + GitBranchDiff, + GitBulkPaths, + GitCommit, + GitCommitCompare, + GitCommitDiff, + GitDiff, + GitFilePath, + GitGenerateCommitMessage, + GitGeneratePullRequestFields, + GitHistory, + GitPush, + GitRemoteFileUrl, + GitStatusParams, + WorktreeSelector +} from './git-params' export const GIT_METHODS: RpcMethod[] = [ defineMethod({ @@ -235,6 +145,44 @@ export const GIT_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.cancelRuntimeGenerateCommitMessage(params.worktree) }), + defineMethod({ + name: 'git.generatePullRequestFields', + params: GitGeneratePullRequestFields, + handler: async (params, { runtime }) => { + const input = { + base: params.base, + title: params.title, + body: params.body, + draft: params.draft + } + if ( + params.commitMessageAi === undefined && + params.agentCmdOverrides === undefined && + params.enableGitHubAttribution === undefined + ) { + return runtime.generateRuntimePullRequestFields(params.worktree, input) + } + return runtime.generateRuntimePullRequestFields(params.worktree, input, { + ...(params.commitMessageAi !== undefined + ? { commitMessageAi: params.commitMessageAi as GlobalSettings['commitMessageAi'] } + : {}), + ...(params.agentCmdOverrides !== undefined + ? { + agentCmdOverrides: params.agentCmdOverrides as GlobalSettings['agentCmdOverrides'] + } + : {}), + ...(params.enableGitHubAttribution !== undefined + ? { enableGitHubAttribution: params.enableGitHubAttribution } + : {}) + }) + } + }), + defineMethod({ + name: 'git.cancelGeneratePullRequestFields', + params: WorktreeSelector, + handler: async (params, { runtime }) => + runtime.cancelRuntimeGeneratePullRequestFields(params.worktree) + }), defineMethod({ name: 'git.stage', params: GitFilePath, diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index 28730ce1e..63f3d2a8d 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -67,6 +67,7 @@ describe('hosted review RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('hostedReview.getCreationEligibility', { repo: 'repo-1', + worktree: 'path:/worktrees/feature', branch: 'feature/create-pr', base: 'origin/main', hasUncommittedChanges: false, @@ -79,6 +80,7 @@ describe('hosted review RPC methods', () => { expect(runtime.getHostedReviewCreationEligibility).toHaveBeenCalledWith({ repoSelector: 'repo-1', + worktreeSelector: 'path:/worktrees/feature', branch: 'feature/create-pr', base: 'origin/main', hasUncommittedChanges: false, @@ -111,6 +113,7 @@ describe('hosted review RPC methods', () => { const response = await dispatcher.dispatch( makeRequest('hostedReview.create', { repo: 'repo-1', + worktree: 'path:/worktrees/feature', provider: 'github', base: 'main', head: 'feature/create-pr', @@ -122,6 +125,7 @@ describe('hosted review RPC methods', () => { expect(runtime.createHostedReview).toHaveBeenCalledWith({ repoSelector: 'repo-1', + worktreeSelector: 'path:/worktrees/feature', provider: 'github', base: 'main', head: 'feature/create-pr', diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index e4d239a38..cd2ccf455 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -14,6 +14,7 @@ const HostedReviewForBranch = z.object({ const HostedReviewCreationEligibility = z.object({ repo: requiredString('Missing repo selector'), + worktree: z.string().min(1, 'Missing worktree selector').optional(), branch: requiredString('Missing branch'), base: z.string().nullable().optional(), hasUncommittedChanges: z.boolean().optional(), @@ -29,6 +30,7 @@ const HostedReviewCreationEligibility = z.object({ const HostedReviewCreate = z.object({ repo: requiredString('Missing repo selector'), + worktree: z.string().min(1, 'Missing worktree selector').optional(), provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']), base: requiredString('Missing base branch'), head: z.string().optional(), @@ -58,6 +60,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getHostedReviewCreationEligibility({ repoSelector: params.repo, + worktreeSelector: params.worktree, branch: params.branch, base: params.base ?? null, hasUncommittedChanges: params.hasUncommittedChanges, @@ -77,6 +80,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.createHostedReview({ repoSelector: params.repo, + worktreeSelector: params.worktree, provider: params.provider, base: params.base, head: params.head, diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 709e254b5..c18f91bad 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -6,7 +6,10 @@ import type * as ChildProcess from 'child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../shared/constants' import { + cancelGenerateCommitMessageLocal, + cancelGeneratePullRequestFieldsLocal, generateCommitMessageFromContext, + generatePullRequestFieldsFromContext, resolveCommitMessageSettings, trimGeneratedCommitMessage } from './commit-message-text-generation' @@ -390,6 +393,94 @@ describe('generateCommitMessageFromContext', () => { ) }) + it('keeps local commit-message and pull-request cancellation lanes separate', async () => { + const children: { + kill: ReturnType + listeners: Map void> + }[] = [] + spawnMock.mockImplementation(() => { + const listeners = new Map void>() + const child = { + pid: 123 + children.length, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + children.push({ kill: child.kill, listeners }) + return child as never + }) + + const commit = generateCommitMessageFromContext( + { + branch: 'main', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + const pullRequest = generatePullRequestFieldsFromContext( + { + branch: 'feature/pr-fields', + base: 'main', + currentTitle: '', + currentBody: '', + currentDraft: false, + commitSummary: '- feat: update README', + changeSummary: 'M\tREADME.md', + patch: '+hello' + }, + { + agentId: 'custom', + model: '', + customAgentCommand: 'agent' + }, + { + kind: 'local', + cwd: '/repo' + } + ) + + cancelGenerateCommitMessageLocal('/repo') + + expect(children[0]?.kill).toHaveBeenCalledWith('SIGKILL') + expect(children[1]?.kill).not.toHaveBeenCalled() + + children[0]?.listeners.get('close')?.(null) + const pullRequestStdout = children[1]?.listeners.get('stdout:data') + pullRequestStdout?.( + Buffer.from('{"base":"main","title":"Update README","body":"Details","draft":false}') + ) + children[1]?.listeners.get('close')?.(0) + + await expect(commit).resolves.toEqual({ + success: false, + error: 'Generation canceled.', + canceled: true + }) + await expect(pullRequest).resolves.toMatchObject({ + success: true, + fields: { + base: 'main', + title: 'Update README', + body: 'Details', + draft: false + } + }) + + cancelGeneratePullRequestFieldsLocal('/repo') + expect(children[1]?.kill).not.toHaveBeenCalled() + }) + it('routes Windows batch-script agent commands through cmd.exe', async () => { const originalComSpec = process.env.ComSpec process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index cb6aa1c31..09949e281 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -9,6 +9,12 @@ import { type CommitMessageDraftContext, type GeneratedCommitMessage } from '../../shared/commit-message-generation' +import { + buildPullRequestFieldsPrompt, + parseGeneratedPullRequestFields, + type GeneratedPullRequestFields, + type PullRequestDraftContext +} from '../../shared/pull-request-generation' import { cleanGeneratedCommitMessage, extractAgentErrorMessage @@ -47,6 +53,10 @@ export type GenerateCommitMessageResult = | { success: true; message: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } +export type GeneratePullRequestFieldsResult = + | { success: true; fields: GeneratedPullRequestFields; agentLabel?: string } + | { success: false; error: string; canceled?: boolean } + export type RemoteCommitMessageExecResult = { stdout: string stderr: string @@ -73,8 +83,8 @@ type ResolveCommitMessageSettingsResult = | { ok: true; params: GenerateCommitMessageParams } | { ok: false; error: string } -type InternalCommitMessageGenerationResult = - | { success: true; commitMessage: GeneratedCommitMessage; agentLabel?: string } +type InternalTextGenerationResult = + | { success: true; rawOutput: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } export function trimGeneratedCommitMessage(message: string): string { @@ -190,23 +200,27 @@ function killProcessTree(child: ChildProcess): void { } } -// Keying by `local:${cwd}` keeps local cancellation independent from any SSH -// worktree with the same remote path. +type LocalGenerationOperation = 'commit-message' | 'pull-request-fields' + +// Keying by operation plus `local:${cwd}` keeps local cancellation independent +// from SSH worktrees and from other generation features in the same worktree. const cancelTokensByLane = new Map void>() -function localLaneKey(cwd: string): string { - return `local:${cwd}` +function localLaneKey(operation: LocalGenerationOperation, cwd: string): string { + return `${operation}:local:${cwd}` } export function cancelGenerateCommitMessageLocal(cwd: string): void { - cancelTokensByLane.get(localLaneKey(cwd))?.() + cancelTokensByLane.get(localLaneKey('commit-message', cwd))?.() } async function runLocalPlan( plan: CommitMessagePlan, cwd: string, - env: NodeJS.ProcessEnv | undefined -): Promise { + env: NodeJS.ProcessEnv | undefined, + emptyResultName = 'message', + operation: LocalGenerationOperation = 'commit-message' +): Promise { const { binary, args, stdinPayload, label } = plan return new Promise((resolve) => { let child: ChildProcess @@ -246,20 +260,24 @@ async function runLocalPlan( let outputLimitExceeded = false let settled = false let canceledByUser = false - const laneKey = localLaneKey(cwd) - const finalize = (result: InternalCommitMessageGenerationResult): void => { + const laneKey = localLaneKey(operation, cwd) + let cancelToken: (() => void) | null = null + const finalize = (result: InternalTextGenerationResult): void => { if (settled) { return } settled = true - cancelTokensByLane.delete(laneKey) + if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) { + cancelTokensByLane.delete(laneKey) + } resolve(result) } - cancelTokensByLane.set(laneKey, () => { + cancelToken = () => { canceledByUser = true killProcessTree(child) - }) + } + cancelTokensByLane.set(laneKey, cancelToken) const timer = setTimeout(() => { killProcessTree(child) @@ -313,7 +331,7 @@ async function runLocalPlan( finalize({ success: false, error: userFacingAgentFailure(label) }) return } - finalizeFromAgentOutput({ code, stdout, stderr, label, finalize }) + finalizeFromAgentOutput({ code, stdout, stderr, label, emptyResultName, finalize }) }) child.stdin?.end(stdinPayload ?? undefined) @@ -325,9 +343,10 @@ function finalizeFromAgentOutput(args: { stdout: string stderr: string label: string - finalize: (result: InternalCommitMessageGenerationResult) => void + emptyResultName: string + finalize: (result: InternalTextGenerationResult) => void }): void { - const { code, stdout, stderr, label, finalize } = args + const { code, stdout, stderr, label, emptyResultName, finalize } = args if (code !== 0) { const safeDetail = sanitizeAgentFailureDetail(extractAgentErrorMessage(stdout, stderr)) console.error('[commit-message] Generator failed:', { @@ -342,21 +361,21 @@ function finalizeFromAgentOutput(args: { } const cleaned = cleanGeneratedCommitMessage(stdout) if (!cleaned) { - finalize({ success: false, error: `${label} returned an empty message.` }) + finalize({ success: false, error: `${label} returned an empty ${emptyResultName}.` }) return } - const commitMessage = splitGeneratedCommitMessage(cleaned) finalize({ success: true, - commitMessage, + rawOutput: cleaned, agentLabel: label }) } async function runRemotePlan( plan: CommitMessagePlan, - target: Extract -): Promise { + target: Extract, + emptyResultName = 'message' +): Promise { const { binary, label } = plan let result: RemoteCommitMessageExecResult try { @@ -403,20 +422,27 @@ async function runRemotePlan( stdout: result.stdout, stderr: result.stderr, label, + emptyResultName, finalize: resolve }) }) } function formatCommitMessageGenerationResult( - result: InternalCommitMessageGenerationResult + result: InternalTextGenerationResult ): GenerateCommitMessageResult { if (!result.success) { return result } + let commitMessage: GeneratedCommitMessage + try { + commitMessage = splitGeneratedCommitMessage(result.rawOutput) + } catch { + return { success: false, error: 'Generated commit message could not be parsed.' } + } return { success: true, - message: trimGeneratedCommitMessage(result.commitMessage.message), + message: trimGeneratedCommitMessage(commitMessage.message), agentLabel: result.agentLabel } } @@ -434,7 +460,47 @@ export async function generateCommitMessageFromContext( const internalResult = target.kind === 'remote' - ? await runRemotePlan(planned.plan, target) - : await runLocalPlan(planned.plan, target.cwd, target.env) + ? await runRemotePlan(planned.plan, target, 'details') + : await runLocalPlan(planned.plan, target.cwd, target.env, 'details') return formatCommitMessageGenerationResult(internalResult) } + +export function cancelGeneratePullRequestFieldsLocal(cwd: string): void { + cancelTokensByLane.get(localLaneKey('pull-request-fields', cwd))?.() +} + +function formatPullRequestFieldsGenerationResult( + result: InternalTextGenerationResult, + context: PullRequestDraftContext +): GeneratePullRequestFieldsResult { + if (!result.success) { + return result + } + try { + return { + success: true, + fields: parseGeneratedPullRequestFields(result.rawOutput, context), + agentLabel: result.agentLabel + } + } catch { + return { success: false, error: 'Generated pull request details could not be parsed.' } + } +} + +export async function generatePullRequestFieldsFromContext( + context: PullRequestDraftContext, + params: GenerateCommitMessageParams, + target: CommitMessageGenerationTarget +): Promise { + const prompt = buildPullRequestFieldsPrompt(context, params.customPrompt ?? '') + const planned = planCommitMessageGeneration(params, prompt) + if (!planned.ok) { + return { success: false, error: planned.error } + } + + const internalResult = + target.kind === 'remote' + ? await runRemotePlan(planned.plan, target) + : await runLocalPlan(planned.plan, target.cwd, target.env, 'details', 'pull-request-fields') + return formatPullRequestFieldsGenerationResult(internalResult, context) +} diff --git a/src/main/text-generation/pull-request-context.ts b/src/main/text-generation/pull-request-context.ts new file mode 100644 index 000000000..12aa93846 --- /dev/null +++ b/src/main/text-generation/pull-request-context.ts @@ -0,0 +1,64 @@ +import type { PullRequestDraftContext } from '../../shared/pull-request-generation' + +const MAX_PULL_REQUEST_CONTEXT_BYTES = 10 * 1024 * 1024 + +type GitExec = ( + args: string[], + options?: { maxBuffer?: number } +) => Promise<{ stdout: string; stderr?: string }> + +export type PullRequestContextInput = { + base: string + currentTitle: string + currentBody: string + currentDraft: boolean +} + +async function safeExec(execGit: GitExec, args: string[]): Promise { + try { + const { stdout } = await execGit(args, { maxBuffer: MAX_PULL_REQUEST_CONTEXT_BYTES }) + return stdout.trim() + } catch { + return '' + } +} + +export async function getPullRequestDraftContext( + execGit: GitExec, + input: PullRequestContextInput +): Promise { + const base = input.base.trim() + if (!base || base.startsWith('-')) { + return null + } + + const [branch, mergeBase] = await Promise.all([ + safeExec(execGit, ['branch', '--show-current']), + safeExec(execGit, ['merge-base', base, 'HEAD']) + ]) + if (!mergeBase) { + return null + } + + const range = `${mergeBase}..HEAD` + const [commitSummary, changeSummary, patch] = await Promise.all([ + safeExec(execGit, ['log', '--pretty=format:- %s', '--max-count=50', range]), + safeExec(execGit, ['diff', '--name-status', range]), + safeExec(execGit, ['diff', '--patch', '--minimal', '--no-color', '--no-ext-diff', range]) + ]) + + if (!commitSummary && !changeSummary && !patch) { + return null + } + + return { + branch: branch || null, + base, + currentTitle: input.currentTitle, + currentBody: input.currentBody, + currentDraft: input.currentDraft, + commitSummary, + changeSummary, + patch + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 12d5a4fc0..ff54e93b0 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1431,6 +1431,25 @@ export type PreloadApi = { worktreePath: string connectionId?: string }) => Promise + generatePullRequestFields: (args: { + worktreePath: string + base: string + title: string + body: string + draft: boolean + connectionId?: string + }) => Promise< + | { + success: true + fields: { base: string; title: string; body: string; draft: boolean } + agentLabel?: string + } + | { success: false; error: string; canceled?: boolean } + > + cancelGeneratePullRequestFields: (args: { + worktreePath: string + connectionId?: string + }) => Promise stage: (args: { worktreePath: string filePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 63936248c..cf75c0818 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1972,6 +1972,18 @@ const api = { worktreePath: string connectionId?: string }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), + generatePullRequestFields: (args: { + worktreePath: string + base: string + title: string + body: string + draft: boolean + connectionId?: string + }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), + cancelGeneratePullRequestFields: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args), stage: (args: { worktreePath: string filePath: string diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 2f8b2b8cd..3071e5d27 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -138,6 +138,7 @@ export default function ChecksPanel(): React.JSX.Element { // differs from the PR's head ref) resolve via the number-based fallback. const linkedPR = activeWorktree?.linkedPR ?? null const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null + const activeWorktreePath = activeWorktree?.path ?? null const stateRequestKey = repo && branch ? checksPanelAsyncResultKey(repo.id, branch, prNumber) : '' asyncResultKeyRef.current = stateRequestKey @@ -160,6 +161,7 @@ export default function ChecksPanel(): React.JSX.Element { let stale = false void getHostedReviewCreationEligibility({ repoPath: repo.path, + ...(activeWorktreePath ? { worktreePath: activeWorktreePath } : {}), branch, base: repo.worktreeBaseRef ?? null, hasUncommittedChanges, @@ -182,6 +184,7 @@ export default function ChecksPanel(): React.JSX.Element { stale = true } }, [ + activeWorktreePath, branch, getHostedReviewCreationEligibility, hasUncommittedChanges, @@ -792,6 +795,8 @@ export default function ChecksPanel(): React.JSX.Element { open={createPrDialogOpen} repoId={repo.id} repoPath={repo.path} + worktreeId={activeWorktreeId} + worktreePath={activeWorktreePath ?? repo.path} branch={branch} eligibility={hostedReviewCreation} pushBeforeCreate={createPrPushFirst} diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx index 318328d00..fe7692ba2 100644 --- a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx +++ b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Check, ChevronsUpDown, Loader2 } from 'lucide-react' +import { Check, ChevronsUpDown, Loader2, Sparkles, Square, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { @@ -12,25 +12,22 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' -import { - getRuntimeRepoBaseRefDefault, - searchRuntimeRepoBaseRefs -} from '@/runtime/runtime-repo-client' import type { CreateHostedReviewResult, HostedReviewCreationEligibility } from '../../../../shared/hosted-review' -import { - normalizeHostedReviewBaseRef, - normalizeHostedReviewHeadRef -} from '../../../../shared/hosted-review-refs' +import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' +import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' type CreatePullRequestDialogProps = { open: boolean repoId: string repoPath: string + worktreeId: string | null + worktreePath: string branch: string eligibility: HostedReviewCreationEligibility | null pushBeforeCreate: boolean @@ -39,10 +36,6 @@ type CreatePullRequestDialogProps = { onCreated: (result: { number: number; url: string }) => Promise } -function stripBaseRef(ref: string): string { - return normalizeHostedReviewBaseRef(ref) -} - function formatCreateError(result: CreateHostedReviewResult, pushed: boolean): string { if (result.ok) { return '' @@ -57,6 +50,8 @@ export function CreatePullRequestDialog({ open, repoId, repoPath, + worktreeId, + worktreePath, branch, eligibility, pushBeforeCreate, @@ -67,93 +62,52 @@ export function CreatePullRequestDialog({ const settings = useAppStore((s) => s.settings) const createHostedReview = useAppStore((s) => s.createHostedReview) const submitInFlightRef = useRef(false) - const initializedFromEligibilityRef = useRef(null) - const [base, setBase] = useState('') - const [title, setTitle] = useState('') - const [body, setBody] = useState('') - const [draft, setDraft] = useState(false) - const [baseQuery, setBaseQuery] = useState('') - const [baseResults, setBaseResults] = useState([]) - const [baseSearchError, setBaseSearchError] = useState(null) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + const { + aiGenerationEnabled, + base, + setBase, + title, + setTitle, + body, + setBody, + draft, + setDraft, + baseQuery, + setBaseQuery, + baseResults, + setBaseResults, + baseSearchError, + generating, + generateError, + generateDisabled, + generateDisabledReason, + handleGenerate, + handleCancelGenerate + } = useCreatePullRequestDialogFields({ + open, + repoId, + worktreeId, + worktreePath, + branch, + eligibility, + settings, + submitting + }) useEffect(() => { - if (!open) { - submitInFlightRef.current = false - initializedFromEligibilityRef.current = null - setSubmitting(false) - setError(null) + if (open) { return } - if (!eligibility) { - return - } - const initializationKey = `${repoId}:${branch}` - if (initializedFromEligibilityRef.current === initializationKey) { - return - } - // Why: eligibility refreshes while the dialog is open; only seed fields - // once per branch so late refreshes (including populated→null transitions - // when a background eligibility fetch errors out) do not overwrite user edits. - initializedFromEligibilityRef.current = initializationKey - const initialBase = eligibility.defaultBaseRef ?? '' - setBase(stripBaseRef(initialBase)) - setTitle(eligibility.title ?? '') - setBody(eligibility.body ?? '') - setDraft(false) - setBaseQuery('') - setBaseResults([]) - setBaseSearchError(null) - }, [branch, eligibility, open, repoId]) - - useEffect(() => { - if (!open || base) { - return - } - let stale = false - void getRuntimeRepoBaseRefDefault(settings, repoId) - .then((result) => { - if (!stale && result.defaultBaseRef) { - setBase(stripBaseRef(result.defaultBaseRef)) - } - }) - .catch(() => undefined) - return () => { - stale = true - } - }, [base, open, repoId, settings]) - - useEffect(() => { - if (!open || baseQuery.trim().length < 2) { - setBaseResults([]) - setBaseSearchError(null) - return - } - let stale = false - const timer = window.setTimeout(() => { - void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20) - .then((results) => { - if (!stale) { - setBaseResults(results.map(stripBaseRef)) - setBaseSearchError(null) - } - }) - .catch(() => { - if (!stale) { - setBaseResults([]) - setBaseSearchError('Branch discovery failed.') - } - }) - }, 200) - return () => { - stale = true - window.clearTimeout(timer) - } - }, [baseQuery, open, repoId, settings]) + submitInFlightRef.current = false + setSubmitting(false) + setError(null) + }, [open]) const submitDisabled = submitting || + generating || title.trim().length === 0 || base.trim().length === 0 || stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase() @@ -181,7 +135,8 @@ export function CreatePullRequestDialog({ head: normalizeHostedReviewHeadRef(branch), title: title.trim(), body, - draft + draft, + worktreePath }) if (result.ok) { toast.success(`Pull request #${result.number} created`, { @@ -228,7 +183,8 @@ export function CreatePullRequestDialog({ pushBeforeCreate, repoPath, submitDisabled, - title + title, + worktreePath ]) const handleOpenChange = useCallback( @@ -247,7 +203,47 @@ export function CreatePullRequestDialog({ - Create Pull Request +
+ Create Pull Request + {aiGenerationEnabled ? ( +
+ {generating ? ( + + + + + + Generating PR details. Click to stop. + + + ) : ( + + )} +
+ ) : null} +
Confirm the target branch and PR details before creating the hosted review. @@ -343,6 +339,7 @@ export function CreatePullRequestDialog({ Choose a different base branch before creating a pull request.

) : null} + {generateError ?

{generateError}

: null} {error ?

{error}

: null} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 4d86a39ad..afcc064b9 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -700,6 +700,7 @@ function SourceControlInner(): React.JSX.Element { let stale = false void getHostedReviewCreationEligibility({ repoPath: activeRepo.path, + ...(worktreePath ? { worktreePath } : {}), branch: branchName, base: effectiveBaseRef ?? null, hasUncommittedChanges: hasUncommittedEntries, @@ -735,7 +736,8 @@ function SourceControlInner(): React.JSX.Element { linkedGitLabMR, remoteStatus?.ahead, remoteStatus?.behind, - remoteStatus?.hasUpstream + remoteStatus?.hasUpstream, + worktreePath ]) const grouped = useMemo(() => { @@ -1306,7 +1308,9 @@ function SourceControlInner(): React.JSX.Element { prState: hostedReview?.state ?? null, isPRStateLoading: isHostedReviewStateLoading, inFlightRemoteOpKind, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead: + branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined }), [ commitMessage, @@ -1319,6 +1323,8 @@ function SourceControlInner(): React.JSX.Element { hostedReviewCreation, isHostedReviewStateLoading, hostedReview?.state, + branchSummary?.commitsAhead, + branchSummary?.status, remoteStatus, unresolvedConflicts.length ] @@ -1338,7 +1344,9 @@ function SourceControlInner(): React.JSX.Element { prState: hostedReview?.state ?? null, isPRStateLoading: isHostedReviewStateLoading, inFlightRemoteOpKind, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead: + branchSummary?.status === 'ready' ? (branchSummary.commitsAhead ?? 0) : undefined }), [ commitMessage, @@ -1351,6 +1359,8 @@ function SourceControlInner(): React.JSX.Element { hostedReviewCreation, isHostedReviewStateLoading, hostedReview?.state, + branchSummary?.commitsAhead, + branchSummary?.status, remoteStatus, unresolvedConflicts.length ] @@ -2395,6 +2405,8 @@ function SourceControlInner(): React.JSX.Element { open={createPrDialogOpen} repoId={activeRepo.id} repoPath={activeRepo.path} + worktreeId={currentWorktreeId} + worktreePath={activeWorktree.path} branch={branchName} eligibility={hostedReviewCreation} pushBeforeCreate={createPrPushFirst} diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 9cfcca3ed..33e361151 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -190,6 +190,21 @@ describe('resolveDropdownItems', () => { expect(byKind.publish.disabled).toBe(false) }) + it('does not show Publish Branch when an unpublished branch has no commits ahead', () => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, + branchCommitsAhead: 0 + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.publish.label).toBe('No Branch Changes') + expect(byKind.publish.title).toBe('Nothing to publish') + expect(byKind.publish.disabled).toBe(true) + }) + it('does not mention Publish Branch when the linked PR is already merged', () => { const items = resolveDropdownItems( inputs({ diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts index eeb116216..1231ca659 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts @@ -65,7 +65,8 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry upstreamStatus, prState, isPRStateLoading, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead } = inputs const hasStaged = stagedCount > 0 @@ -80,6 +81,7 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry const hasUpstream = upstreamStatus?.hasUpstream ?? false const publishBlockedByMergedPR = !hasUpstream && prState === 'merged' const publishBlockedByPRLoading = !hasUpstream && !!isPRStateLoading + const publishBlockedByNoBranchCommits = !hasUpstream && branchCommitsAhead === 0 const ahead = upstreamStatus?.ahead ?? 0 const behind = upstreamStatus?.behind ?? 0 @@ -229,22 +231,30 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry const publishItem: DropdownItem = { kind: 'publish', - label: publishBlockedByMergedPR || publishBlockedByPRLoading ? 'PR Status' : 'Publish Branch', + label: + publishBlockedByMergedPR || publishBlockedByPRLoading + ? 'PR Status' + : publishBlockedByNoBranchCommits + ? 'No Branch Changes' + : 'Publish Branch', title: upstreamLoading ? 'Checking branch status…' : publishBlockedByPRLoading ? 'Checking PR status…' : publishBlockedByMergedPR ? 'PR is already merged' - : hasUpstream - ? 'Branch is already published' - : 'Publish this branch to origin', + : publishBlockedByNoBranchCommits + ? 'Nothing to publish' + : hasUpstream + ? 'Branch is already published' + : 'Publish this branch to origin', disabled: globalBusy || upstreamLoading || hasUpstream || publishBlockedByPRLoading || - publishBlockedByMergedPR + publishBlockedByMergedPR || + publishBlockedByNoBranchCommits } const createBlockedHint = (() => { diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts index dbec18d24..6fec64683 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts @@ -174,7 +174,7 @@ describe('resolvePrimaryAction', () => { it('returns Publish Branch on a clean tree when no upstream exists', () => { const result = resolvePrimaryAction( - inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } }) + inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 1 }) ) expect(result).toEqual({ kind: 'publish', @@ -184,6 +184,18 @@ describe('resolvePrimaryAction', () => { }) }) + it('does not offer Publish Branch when an unpublished branch has no commits ahead', () => { + const result = resolvePrimaryAction( + inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 0 }) + ) + expect(result).toEqual({ + kind: 'commit', + label: 'Commit', + title: 'Nothing to commit. Branch has no changes to publish.', + disabled: true + }) + }) + it.each([ [{ prState: 'merged' as const }, 'Nothing to commit. PR is already merged.'], [{ isPRStateLoading: true }, 'Checking PR status…'] diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts index b5266a5e1..e6bc470dc 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts @@ -56,6 +56,10 @@ export type PrimaryActionInputs = { // stale label that no longer matches what the slice is doing. inFlightRemoteOpKind?: RemoteOpKind | null hostedReviewCreation?: HostedReviewCreationEligibility | null + // Why: an unpublished branch is only worth publishing when it actually + // carries commits beyond the compare base. Undefined preserves the old + // behavior while the branch compare request is still unavailable/loading. + branchCommitsAhead?: number } const PRIMARY_LABEL_BY_KIND: Record, string> = { @@ -112,7 +116,8 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction prState, isPRStateLoading, inFlightRemoteOpKind, - hostedReviewCreation + hostedReviewCreation, + branchCommitsAhead } = inputs // 1. Commit in flight — lock the primary no matter what else is true. @@ -245,6 +250,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction } if (!upstreamStatus.hasUpstream) { + if (branchCommitsAhead === 0) { + return { + kind: 'commit', + label: 'Commit', + title: 'Nothing to commit. Branch has no changes to publish.', + disabled: true + } + } + if (isPRStateLoading) { return { kind: 'commit', diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts new file mode 100644 index 000000000..37bee7d33 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { getConnectionId } from '@/lib/connection-context' +import { useAppStore, type AppState } from '@/store' +import { + cancelRuntimeGeneratePullRequestFields, + generateRuntimePullRequestFields +} from '@/runtime/runtime-git-client' +import { + getRuntimeRepoBaseRefDefault, + searchRuntimeRepoBaseRefs +} from '@/runtime/runtime-repo-client' +import { + isCustomAgentId, + resolveCommitMessageAgentChoice +} from '../../../../shared/commit-message-agent-spec' +import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' +import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs' + +type UseCreatePullRequestDialogFieldsOptions = { + open: boolean + repoId: string + worktreeId: string | null + worktreePath: string + branch: string + eligibility: HostedReviewCreationEligibility | null + settings: AppState['settings'] + submitting: boolean +} + +type GenerationSeed = { + requestId: number + base: string + title: string + body: string + draft: boolean +} + +export function stripBaseRef(ref: string): string { + return normalizeHostedReviewBaseRef(ref) +} + +export function useCreatePullRequestDialogFields({ + open, + repoId, + worktreeId, + worktreePath, + branch, + eligibility, + settings, + submitting +}: UseCreatePullRequestDialogFieldsOptions) { + const commitMessageAi = settings?.commitMessageAi + const effectiveCommitMessageAgentId = resolveCommitMessageAgentChoice( + commitMessageAi?.agentId, + settings?.defaultTuiAgent + ) + const initializedFromEligibilityRef = useRef(null) + const generateInFlightRef = useRef(false) + const generationRequestIdRef = useRef(0) + const generationSeedRef = useRef(null) + const latestFieldsRef = useRef({ + base: '', + title: '', + body: '', + draft: false + }) + const [base, setBase] = useState('') + const [title, setTitle] = useState('') + const [body, setBody] = useState('') + const [draft, setDraft] = useState(false) + const [baseQuery, setBaseQuery] = useState('') + const [baseResults, setBaseResults] = useState([]) + const [baseSearchError, setBaseSearchError] = useState(null) + const [generating, setGenerating] = useState(false) + const [generateError, setGenerateError] = useState(null) + + useEffect(() => { + latestFieldsRef.current = { base, title, body, draft } + }, [base, body, draft, title]) + + useEffect(() => { + if (!open) { + generationRequestIdRef.current += 1 + if (generateInFlightRef.current && worktreePath) { + const connectionId = getConnectionId(worktreeId) ?? undefined + void cancelRuntimeGeneratePullRequestFields({ + settings, + worktreeId, + worktreePath, + connectionId + }) + } + generateInFlightRef.current = false + generationSeedRef.current = null + initializedFromEligibilityRef.current = null + setGenerating(false) + setGenerateError(null) + return + } + if (!eligibility) { + return + } + const initializationKey = `${repoId}:${branch}` + if (initializedFromEligibilityRef.current === initializationKey) { + return + } + // Why: eligibility refreshes while the dialog is open; only seed fields + // once per branch so late refreshes do not overwrite user edits. + initializedFromEligibilityRef.current = initializationKey + const initialBase = eligibility.defaultBaseRef ?? '' + setBase(stripBaseRef(initialBase)) + setTitle(eligibility.title ?? '') + setBody(eligibility.body ?? '') + setDraft(false) + setBaseQuery('') + setBaseResults([]) + setBaseSearchError(null) + setGenerateError(null) + }, [branch, eligibility, open, repoId, settings, worktreeId, worktreePath]) + + useEffect(() => { + if (!open || base) { + return + } + let stale = false + void getRuntimeRepoBaseRefDefault(settings, repoId) + .then((result) => { + if (!stale && result.defaultBaseRef) { + setBase(stripBaseRef(result.defaultBaseRef)) + } + }) + .catch(() => undefined) + return () => { + stale = true + } + }, [base, open, repoId, settings]) + + useEffect(() => { + if (!open || baseQuery.trim().length < 2) { + setBaseResults([]) + setBaseSearchError(null) + return + } + let stale = false + const timer = window.setTimeout(() => { + void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20) + .then((results) => { + if (!stale) { + setBaseResults(results.map(stripBaseRef)) + setBaseSearchError(null) + } + }) + .catch(() => { + if (!stale) { + setBaseResults([]) + setBaseSearchError('Branch discovery failed.') + } + }) + }, 200) + return () => { + stale = true + window.clearTimeout(timer) + } + }, [baseQuery, open, repoId, settings]) + + let generateDisabledReason: string | undefined + if (submitting) { + generateDisabledReason = 'Create PR in progress...' + } else if (!commitMessageAi?.enabled) { + generateDisabledReason = 'Enable AI commit messages in Settings -> Git.' + } else if (!effectiveCommitMessageAgentId) { + generateDisabledReason = 'Pick an agent in Settings -> Git -> AI Commit Messages.' + } else if (isCustomAgentId(effectiveCommitMessageAgentId)) { + const command = commitMessageAi.customAgentCommand?.trim() ?? '' + if (!command) { + generateDisabledReason = + 'Custom command is empty. Add one in Settings -> Git -> AI Commit Messages.' + } + } else if (!base.trim()) { + generateDisabledReason = 'Choose a base branch before generating.' + } + const generateDisabled = !generating && Boolean(generateDisabledReason) + + const handleGenerate = useCallback(async (): Promise => { + if (!worktreePath || !base.trim() || generateInFlightRef.current || generateDisabled) { + return + } + const requestId = generationRequestIdRef.current + 1 + generationRequestIdRef.current = requestId + const seed = { requestId, base, title, body, draft } + generationSeedRef.current = seed + generateInFlightRef.current = true + setGenerating(true) + setGenerateError(null) + try { + const connectionId = getConnectionId(worktreeId) ?? undefined + const result = await generateRuntimePullRequestFields( + { + settings: useAppStore.getState().settings, + worktreeId, + worktreePath, + connectionId + }, + { + base: stripBaseRef(base.trim()), + title, + body, + draft + } + ) + if (generationRequestIdRef.current !== requestId) { + return + } + if (!result.success) { + if (result.canceled) { + setGenerateError(null) + return + } + setGenerateError(result.error) + return + } + + const currentSeed = generationSeedRef.current + const latestFields = latestFieldsRef.current + if ( + !currentSeed || + currentSeed.requestId !== requestId || + currentSeed.base !== latestFields.base || + currentSeed.title !== latestFields.title || + currentSeed.body !== latestFields.body || + currentSeed.draft !== latestFields.draft + ) { + setGenerateError('Fields changed while generating. Run generate again for a fresh draft.') + return + } + setBase(stripBaseRef(result.fields.base)) + setBaseQuery('') + setBaseResults([]) + setTitle(result.fields.title) + setBody(result.fields.body) + setDraft(result.fields.draft) + setGenerateError(null) + } catch (error) { + if (generationRequestIdRef.current !== requestId) { + return + } + setGenerateError( + error instanceof Error ? error.message : 'Failed to generate pull request details' + ) + } finally { + if (generationRequestIdRef.current === requestId) { + generateInFlightRef.current = false + generationSeedRef.current = null + setGenerating(false) + } + } + }, [base, body, draft, generateDisabled, title, worktreeId, worktreePath]) + + const handleCancelGenerate = useCallback((): void => { + if (!worktreePath || !generateInFlightRef.current) { + return + } + generationRequestIdRef.current += 1 + generateInFlightRef.current = false + generationSeedRef.current = null + setGenerating(false) + setGenerateError(null) + const connectionId = getConnectionId(worktreeId) ?? undefined + void cancelRuntimeGeneratePullRequestFields({ + settings: useAppStore.getState().settings, + worktreeId, + worktreePath, + connectionId + }) + }, [worktreeId, worktreePath]) + + return { + aiGenerationEnabled: commitMessageAi?.enabled === true, + base, + setBase, + title, + setTitle, + body, + setBody, + draft, + setDraft, + baseQuery, + setBaseQuery, + baseResults, + setBaseResults, + baseSearchError, + generating, + generateError, + generateDisabled, + generateDisabledReason, + handleGenerate, + handleCancelGenerate + } +} diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index d11f78191..ebd64b5e0 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -18,6 +18,14 @@ export type RuntimeGenerateCommitMessageResult = | { success: true; message: string; agentLabel?: string } | { success: false; error: string; canceled?: boolean } +export type RuntimeGeneratePullRequestFieldsResult = + | { + success: true + fields: { base: string; title: string; body: string; draft: boolean } + agentLabel?: string + } + | { success: false; error: string; canceled?: boolean } + type RuntimeGitSettings = Pick & Partial> @@ -356,6 +364,49 @@ export async function cancelRuntimeGenerateCommitMessage( ) } +export async function generateRuntimePullRequestFields( + context: RuntimeGitContext, + input: { base: string; title: string; body: string; draft: boolean } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.generatePullRequestFields({ + worktreePath: context.worktreePath, + connectionId: context.connectionId, + ...input + }) as Promise + } + return callRuntimeRpc( + target, + 'git.generatePullRequestFields', + { + worktree: context.worktreeId, + ...input, + ...getRuntimeCommitMessageSettings(context.settings) + }, + { timeoutMs: 75_000 } + ) +} + +export async function cancelRuntimeGeneratePullRequestFields( + context: RuntimeGitContext +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + await window.api.git.cancelGeneratePullRequestFields({ + worktreePath: context.worktreePath, + connectionId: context.connectionId + }) + return + } + await callRuntimeRpc( + target, + 'git.cancelGeneratePullRequestFields', + { worktree: context.worktreeId }, + { timeoutMs: 5_000 } + ) +} + export async function stageRuntimeGitPath( context: RuntimeGitContext, filePath: string diff --git a/src/renderer/src/store/slices/hosted-review-cache.test.ts b/src/renderer/src/store/slices/hosted-review-cache.test.ts new file mode 100644 index 000000000..46b64cdf9 --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review-cache.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import type { AppState } from '../types' +import { createHostedReviewSlice, getHostedReviewCacheKey } from './hosted-review' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' + +const runtimeRpc = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: runtimeRpc.callRuntimeRpc, + getActiveRuntimeTarget: ( + settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined + ) => { + const environmentId = settings?.activeRuntimeEnvironmentId?.trim() + return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' } + } +})) + +const mockApi = { + hostedReview: { + forBranch: vi.fn(), + getCreationEligibility: vi.fn(), + create: vi.fn() + } +} + +globalThis.window = { api: mockApi } as never + +function makeStore(settings: AppState['settings'] = null) { + return create< + Pick< + AppState, + | 'hostedReviewCache' + | 'fetchHostedReviewForBranch' + | 'getHostedReviewCreationEligibility' + | 'createHostedReview' + | 'settings' + | 'repos' + > + >()((...args) => ({ + settings, + repos: [{ id: 'repo-1', path: '/repo', connectionId: null } as AppState['repos'][number]], + ...createHostedReviewSlice(...(args as Parameters)) + })) +} + +const review: HostedReviewInfo = { + provider: 'gitlab', + number: 5, + title: 'Shared MR status', + state: 'open', + url: 'https://gitlab.com/g/p/-/merge_requests/5', + status: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'MERGEABLE' +} + +describe('hosted review cache revalidation', () => { + beforeEach(() => { + mockApi.hostedReview.forBranch.mockReset() + mockApi.hostedReview.getCreationEligibility.mockReset() + mockApi.hostedReview.create.mockReset() + runtimeRpc.callRuntimeRpc.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('dedupes repeated linked PR retries while a stronger lookup is in flight', async () => { + let resolveLinkedLookup: (value: typeof review) => void = () => {} + const linkedLookup = new Promise((resolve) => { + resolveLinkedLookup = resolve + }) + mockApi.hostedReview.forBranch.mockResolvedValueOnce(null).mockReturnValueOnce(linkedLookup) + const store = makeStore() + + await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe( + null + ) + + const firstLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42 + }) + const secondLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42 + }) + + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) + resolveLinkedLookup(review) + await expect(firstLinkedFetch).resolves.toEqual(review) + await expect(secondLinkedFetch).resolves.toEqual(review) + }) + + it('serves stale hosted review metadata while revalidating in the background', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const updatedReview: HostedReviewInfo = { + ...review, + title: 'Updated linked PR status', + status: 'failure', + updatedAt: '2026-05-10T00:01:01.000Z' + } + let resolveRefresh: (value: typeof updatedReview) => void = () => {} + const refresh = new Promise((resolve) => { + resolveRefresh = resolve + }) + mockApi.hostedReview.forBranch + .mockResolvedValueOnce(review) + .mockReturnValueOnce(refresh as Promise) + const store = makeStore() + + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42 + }) + ).resolves.toEqual(review) + vi.setSystemTime(60_001) + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42, + staleWhileRevalidate: true + }) + ).resolves.toEqual(review) + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42, + staleWhileRevalidate: true + }) + ).resolves.toEqual(review) + + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) + const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr') + expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review) + + resolveRefresh(updatedReview) + await refresh + await Promise.resolve() + + expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(updatedReview) + }) + + it('does not serve stale metadata when a stronger linked PR hint changes the lookup', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const linkedReview: HostedReviewInfo = { + ...review, + provider: 'github', + number: 42, + title: 'Exact linked PR', + url: 'https://github.com/acme/orca/pull/42' + } + mockApi.hostedReview.forBranch.mockResolvedValueOnce(review).mockResolvedValueOnce(linkedReview) + const store = makeStore() + + await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe( + review + ) + vi.setSystemTime(60_001) + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { + linkedGitHubPR: 42, + staleWhileRevalidate: true + }) + ).resolves.toEqual(linkedReview) + + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 1cfeedd47..0086cddaa 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -1,11 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' -import { - createHostedReviewSlice, - getHostedReviewCacheKey, - refreshHostedReviewCard -} from './hosted-review' +import { createHostedReviewSlice, refreshHostedReviewCard } from './hosted-review' import type { HostedReviewInfo } from '../../../../shared/hosted-review' const runtimeRpc = vi.hoisted(() => ({ @@ -24,19 +20,30 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ const mockApi = { hostedReview: { - forBranch: vi.fn() + forBranch: vi.fn(), + getCreationEligibility: vi.fn(), + create: vi.fn() } } globalThis.window = { api: mockApi } as never function makeStore(settings: AppState['settings'] = null) { - return create>()( - (...args) => ({ - settings, - ...createHostedReviewSlice(...(args as Parameters)) - }) - ) + return create< + Pick< + AppState, + | 'hostedReviewCache' + | 'fetchHostedReviewForBranch' + | 'getHostedReviewCreationEligibility' + | 'createHostedReview' + | 'settings' + | 'repos' + > + >()((...args) => ({ + settings, + repos: [{ id: 'repo-1', path: '/repo', connectionId: null } as AppState['repos'][number]], + ...createHostedReviewSlice(...(args as Parameters)) + })) } const review: HostedReviewInfo = { @@ -53,6 +60,8 @@ const review: HostedReviewInfo = { describe('hosted review slice', () => { beforeEach(() => { mockApi.hostedReview.forBranch.mockReset() + mockApi.hostedReview.getCreationEligibility.mockReset() + mockApi.hostedReview.create.mockReset() runtimeRpc.callRuntimeRpc.mockReset() }) @@ -115,6 +124,100 @@ describe('hosted review slice', () => { ) }) + it('forwards the selected worktree path when creating a local pull request', async () => { + mockApi.hostedReview.create.mockResolvedValueOnce({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + const store = makeStore() + + await expect( + store.getState().createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'Create PR', + worktreePath: '/worktrees/feature' + }) + ).resolves.toMatchObject({ ok: true, number: 12 }) + + expect(mockApi.hostedReview.create).toHaveBeenCalledWith({ + repoPath: '/repo', + connectionId: null, + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'Create PR', + worktreePath: '/worktrees/feature' + }) + }) + + it('uses the selected worktree selector for runtime pull request creation', async () => { + runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + const store = makeStore({ + activeRuntimeEnvironmentId: 'env-win' + } as AppState['settings']) + + await store.getState().createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'Create PR', + worktreePath: 'C:\\worktrees\\feature' + }) + + expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-win' }, + 'hostedReview.create', + { + repo: 'repo-1', + worktree: 'path:C:\\worktrees\\feature', + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'Create PR' + }, + { timeoutMs: 60_000 } + ) + }) + + it('uses the selected worktree selector for runtime pull request creation eligibility', async () => { + runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({ + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null + }) + const store = makeStore({ + activeRuntimeEnvironmentId: 'env-win' + } as AppState['settings']) + + await store.getState().getHostedReviewCreationEligibility({ + repoPath: '/repo', + worktreePath: 'C:\\worktrees\\feature', + branch: 'feature/create-pr', + base: 'main' + }) + + expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-win' }, + 'hostedReview.getCreationEligibility', + { + repo: 'repo-1', + worktree: 'path:C:\\worktrees\\feature', + branch: 'feature/create-pr', + base: 'main' + }, + { timeoutMs: 30_000 } + ) + }) + it('forces card refresh with repo-scoped identity and linked review ids', async () => { const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null) await refreshHostedReviewCard(fetchHostedReviewForBranch, { @@ -187,104 +290,4 @@ describe('hosted review slice', () => { await expect(plainFetch).resolves.toBeNull() await expect(linkedFetch).resolves.toEqual(review) }) - - it('dedupes repeated linked PR retries while a stronger lookup is in flight', async () => { - let resolveLinkedLookup: (value: typeof review) => void = () => {} - const linkedLookup = new Promise((resolve) => { - resolveLinkedLookup = resolve - }) - mockApi.hostedReview.forBranch.mockResolvedValueOnce(null).mockReturnValueOnce(linkedLookup) - const store = makeStore() - - await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe( - null - ) - - const firstLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42 - }) - const secondLinkedFetch = store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42 - }) - - expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) - resolveLinkedLookup(review) - await expect(firstLinkedFetch).resolves.toEqual(review) - await expect(secondLinkedFetch).resolves.toEqual(review) - }) - - it('serves stale hosted review metadata while revalidating in the background', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const updatedReview: HostedReviewInfo = { - ...review, - title: 'Updated linked PR status', - status: 'failure', - updatedAt: '2026-05-10T00:01:01.000Z' - } - let resolveRefresh: (value: typeof updatedReview) => void = () => {} - const refresh = new Promise((resolve) => { - resolveRefresh = resolve - }) - mockApi.hostedReview.forBranch - .mockResolvedValueOnce(review) - .mockReturnValueOnce(refresh as Promise) - const store = makeStore() - - await expect( - store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42 - }) - ).resolves.toEqual(review) - vi.setSystemTime(60_001) - await expect( - store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42, - staleWhileRevalidate: true - }) - ).resolves.toEqual(review) - await expect( - store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42, - staleWhileRevalidate: true - }) - ).resolves.toEqual(review) - - expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) - const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr') - expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review) - - resolveRefresh(updatedReview) - await refresh - await Promise.resolve() - - expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(updatedReview) - }) - - it('does not serve stale metadata when a stronger linked PR hint changes the lookup', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const linkedReview: HostedReviewInfo = { - ...review, - provider: 'github', - number: 42, - title: 'Exact linked PR', - url: 'https://github.com/acme/orca/pull/42' - } - mockApi.hostedReview.forBranch.mockResolvedValueOnce(review).mockResolvedValueOnce(linkedReview) - const store = makeStore() - - await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe( - review - ) - vi.setSystemTime(60_001) - await expect( - store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', { - linkedGitHubPR: 42, - staleWhileRevalidate: true - }) - ).resolves.toEqual(linkedReview) - - expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) - }) }) diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 085b8ebc2..1a5dd5f84 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -134,12 +134,16 @@ export const createHostedReviewSlice: StateCreator candidate.path === args.repoPath) - const { repoPath: _repoPath, ...runtimeArgs } = args + const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args void _repoPath return callRuntimeRpc( target, 'hostedReview.getCreationEligibility', - { repo: repo?.id ?? args.repoPath, ...runtimeArgs }, + { + repo: repo?.id ?? args.repoPath, + ...(worktreePath ? { worktree: `path:${worktreePath}` } : {}), + ...runtimeArgs + }, { timeoutMs: 30_000 } ) } @@ -155,10 +159,15 @@ export const createHostedReviewSlice: StateCreator candidate.path === repoPath) + const { worktreePath, ...runtimeInput } = input return callRuntimeRpc( target, 'hostedReview.create', - { repo: repo?.id ?? repoPath, ...input }, + { + repo: repo?.id ?? repoPath, + ...(worktreePath ? { worktree: `path:${worktreePath}` } : {}), + ...runtimeInput + }, { timeoutMs: 60_000 } ) } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index f51f9a024..27bee11fa 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -553,6 +553,11 @@ function createGitApi(): NonNullable['git']> { error: 'Commit message generation is unavailable in the web client.' }), cancelGenerateCommitMessage: () => Promise.resolve(), + generatePullRequestFields: async () => ({ + success: false, + error: 'Pull request detail generation is unavailable in the web client.' + }), + cancelGeneratePullRequestFields: () => Promise.resolve(), stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath), bulkStage: async ({ worktreePath, filePaths }) => mutateGitPaths('git.bulkStage', worktreePath, filePaths), diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 7c86785b4..3424b405d 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -46,6 +46,7 @@ export type CreateHostedReviewInput = { title: string body?: string draft?: boolean + worktreePath?: string } export type CreateHostedReviewArgs = CreateHostedReviewInput & { @@ -108,6 +109,7 @@ export type HostedReviewCreationEligibility = { export type HostedReviewCreationEligibilityArgs = { repoPath: string + worktreePath?: string connectionId?: string | null branch: string base?: string | null diff --git a/src/shared/pull-request-generation.test.ts b/src/shared/pull-request-generation.test.ts new file mode 100644 index 000000000..934be1376 --- /dev/null +++ b/src/shared/pull-request-generation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { + buildPullRequestFieldsPrompt, + parseGeneratedPullRequestFields, + type PullRequestDraftContext +} from './pull-request-generation' + +const context: PullRequestDraftContext = { + branch: 'feature/pr-details', + base: 'main', + currentTitle: 'Feature pr details', + currentBody: '- Add form', + currentDraft: false, + commitSummary: '- feat: add generated PR details', + changeSummary: 'M\tsrc/file.ts', + patch: 'diff --git a/src/file.ts b/src/file.ts\n+export const value = true' +} + +describe('buildPullRequestFieldsPrompt', () => { + it('asks for compact JSON and includes PR context', () => { + const prompt = buildPullRequestFieldsPrompt(context, 'Use conventional PR titles.') + + expect(prompt).toContain('Return ONLY compact JSON') + expect(prompt).toContain('Head branch: feature/pr-details') + expect(prompt).toContain('Current base: main') + expect(prompt).toContain('Additional instructions from user:') + expect(prompt).toContain('Use conventional PR titles.') + }) +}) + +describe('parseGeneratedPullRequestFields', () => { + it('parses fenced JSON output', () => { + const fields = parseGeneratedPullRequestFields( + '```json\n{"base":"main","title":"fix: add details.","body":"Summary","draft":true}\n```', + context + ) + + expect(fields).toEqual({ + base: 'main', + title: 'fix: add details', + body: 'Summary', + draft: true + }) + }) + + it('falls back for missing optional values', () => { + const fields = parseGeneratedPullRequestFields('{"title":""}', context) + + expect(fields).toEqual({ + base: 'main', + title: 'Feature pr details', + body: '- Add form', + draft: false + }) + }) +}) diff --git a/src/shared/pull-request-generation.ts b/src/shared/pull-request-generation.ts new file mode 100644 index 000000000..96ed52be8 --- /dev/null +++ b/src/shared/pull-request-generation.ts @@ -0,0 +1,114 @@ +import { truncateDiffForPrompt } from './commit-message-prompt' + +export type PullRequestDraftContext = { + branch: string | null + base: string + currentTitle: string + currentBody: string + currentDraft: boolean + commitSummary: string + changeSummary: string + patch: string +} + +export type GeneratedPullRequestFields = { + base: string + title: string + body: string + draft: boolean +} + +function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value + } + const omitted = value.length - maxChars + return `${value.slice(0, maxChars)}\n\n[truncated: ${omitted} characters omitted]` +} + +export function buildPullRequestFieldsPrompt( + context: PullRequestDraftContext, + customInstructions: string +): string { + const base = [ + 'You are generating pull request details.', + 'Return ONLY compact JSON with this exact shape:', + '{"base":"branch-name","title":"short title","body":"markdown description","draft":false}', + '', + 'Rules:', + '- Use the branch diff and commits below as source of truth.', + '- Keep the base branch as the current base unless the diff clearly targets a different branch.', + '- Title: concise, specific, no trailing period.', + '- Body: useful Markdown summary for reviewers. Include testing notes only when evidence exists.', + '- draft: true only when the changes clearly look unfinished, WIP, or unsafe to review.', + '- Do not include labels, reviewers, code fences, prose, or any keys beyond base/title/body/draft.', + '', + `Head branch: ${context.branch ?? '(detached)'}`, + `Current base: ${context.base}`, + `Current title: ${context.currentTitle || '(empty)'}`, + `Current description: ${context.currentBody || '(empty)'}`, + `Current draft: ${context.currentDraft ? 'true' : 'false'}`, + '', + 'Commits:', + limitSection(context.commitSummary || '(none)', 8_000), + '', + 'Changed files:', + limitSection(context.changeSummary || '(none)', 8_000), + '', + 'Patch:', + '```diff', + truncateDiffForPrompt(context.patch), + '```' + ].join('\n') + + const trimmedInstructions = customInstructions.trim() + if (!trimmedInstructions) { + return base + } + return [ + base, + '', + 'Additional instructions from user:', + limitSection(trimmedInstructions, 4_000) + ].join('\n') +} + +function stripJsonFence(raw: string): string { + let text = raw.replace(/\r\n/g, '\n').trim() + const fenced = text.match(/^```(?:json)?\n([\s\S]*?)\n```$/i) + if (fenced) { + text = fenced[1].trim() + } + const start = text.indexOf('{') + const end = text.lastIndexOf('}') + if (start !== -1 && end > start) { + return text.slice(start, end + 1) + } + return text +} + +export function parseGeneratedPullRequestFields( + raw: string, + fallback: Pick +): GeneratedPullRequestFields { + const parsed = JSON.parse(stripJsonFence(raw)) as unknown + if (!parsed || typeof parsed !== 'object') { + throw new Error('Expected a JSON object.') + } + const record = parsed as Record + const base = typeof record.base === 'string' ? record.base.trim() : fallback.base + const title = + typeof record.title === 'string' && record.title.trim() + ? record.title.trim().replace(/[.]+$/g, '') + : fallback.currentTitle.trim() + const body = + typeof record.body === 'string' ? record.body.replace(/\s+$/g, '') : fallback.currentBody + const draft = typeof record.draft === 'boolean' ? record.draft : fallback.currentDraft + + return { + base: base || fallback.base, + title: title || 'Update project files', + body, + draft + } +}