fix: address review findings (#2138)

This commit is contained in:
Jinjing 2026-05-16 22:56:42 -07:00 committed by GitHub
parent c7ac8a1f2f
commit 40e903d833
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1775 additions and 366 deletions

View File

@ -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<GeneratePullRequestFieldsResult> => {
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<void> => {
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 (

View File

@ -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<string> {
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,

View File

@ -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<GeneratePullRequestFieldsResult> {
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)

View File

@ -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()

View File

@ -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<HostedReviewCreationEligibilityArgs, 'repoPath'> & { repoSelector: string }
args: Omit<HostedReviewCreationEligibilityArgs, 'repoPath'> & {
repoSelector: string
worktreeSelector?: string
}
): Promise<HostedReviewCreationEligibility> {
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<CreateHostedReviewResult> {
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,

View File

@ -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)
})

View File

@ -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,

View File

@ -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',

View File

@ -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,

View File

@ -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<typeof vi.fn>
listeners: Map<string, (value: unknown) => void>
}[] = []
spawnMock.mockImplementation(() => {
const listeners = new Map<string, (value: unknown) => 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'

View File

@ -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<string, () => 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<InternalCommitMessageGenerationResult> {
env: NodeJS.ProcessEnv | undefined,
emptyResultName = 'message',
operation: LocalGenerationOperation = 'commit-message'
): Promise<InternalTextGenerationResult> {
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<CommitMessageGenerationTarget, { kind: 'remote' }>
): Promise<InternalCommitMessageGenerationResult> {
target: Extract<CommitMessageGenerationTarget, { kind: 'remote' }>,
emptyResultName = 'message'
): Promise<InternalTextGenerationResult> {
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<GeneratePullRequestFieldsResult> {
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)
}

View File

@ -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<string> {
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<PullRequestDraftContext | null> {
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
}
}

View File

@ -1431,6 +1431,25 @@ export type PreloadApi = {
worktreePath: string
connectionId?: string
}) => Promise<void>
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<void>
stage: (args: {
worktreePath: string
filePath: string

View File

@ -1972,6 +1972,18 @@ const api = {
worktreePath: string
connectionId?: string
}): Promise<void> => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args),
generatePullRequestFields: (args: {
worktreePath: string
base: string
title: string
body: string
draft: boolean
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:generatePullRequestFields', args),
cancelGeneratePullRequestFields: (args: {
worktreePath: string
connectionId?: string
}): Promise<void> => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args),
stage: (args: {
worktreePath: string
filePath: string

View File

@ -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}

View File

@ -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<void>
}
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<string | null>(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<string[]>([])
const [baseSearchError, setBaseSearchError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create Pull Request</DialogTitle>
<div className="flex min-w-0 items-center justify-between gap-2 pr-8">
<DialogTitle className="min-w-0 truncate">Create Pull Request</DialogTitle>
{aiGenerationEnabled ? (
<div className="shrink-0">
{generating ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancelGenerate}
title="Stop generating"
aria-label="Stop generating pull request details"
>
<RefreshCw className="size-4 animate-spin" />
Generating
<Square className="size-3 fill-current" />
</Button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
Generating PR details. Click to stop.
</TooltipContent>
</Tooltip>
) : (
<Button
type="button"
variant="outline"
size="sm"
disabled={generateDisabled}
onClick={() => void handleGenerate()}
title={generateDisabledReason ?? 'Generate pull request details with AI'}
aria-label="Generate pull request details with AI"
>
<Sparkles className="size-4" />
Generate with AI
</Button>
)}
</div>
) : null}
</div>
<DialogDescription>
Confirm the target branch and PR details before creating the hosted review.
</DialogDescription>
@ -343,6 +339,7 @@ export function CreatePullRequestDialog({
Choose a different base branch before creating a pull request.
</p>
) : null}
{generateError ? <p className="text-xs text-destructive">{generateError}</p> : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>

View File

@ -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}

View File

@ -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({

View File

@ -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 = (() => {

View File

@ -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…']

View File

@ -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<Exclude<PrimaryActionKind, 'commit'>, 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',

View File

@ -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<string | null>(null)
const generateInFlightRef = useRef(false)
const generationRequestIdRef = useRef(0)
const generationSeedRef = useRef<GenerationSeed | null>(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<string[]>([])
const [baseSearchError, setBaseSearchError] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(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<void> => {
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
}
}

View File

@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> &
Partial<Pick<GlobalSettings, 'commitMessageAi' | 'agentCmdOverrides' | 'enableGitHubAttribution'>>
@ -356,6 +364,49 @@ export async function cancelRuntimeGenerateCommitMessage(
)
}
export async function generateRuntimePullRequestFields(
context: RuntimeGitContext,
input: { base: string; title: string; body: string; draft: boolean }
): Promise<RuntimeGeneratePullRequestFieldsResult> {
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<RuntimeGeneratePullRequestFieldsResult>
}
return callRuntimeRpc<RuntimeGeneratePullRequestFieldsResult>(
target,
'git.generatePullRequestFields',
{
worktree: context.worktreeId,
...input,
...getRuntimeCommitMessageSettings(context.settings)
},
{ timeoutMs: 75_000 }
)
}
export async function cancelRuntimeGeneratePullRequestFields(
context: RuntimeGitContext
): Promise<void> {
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

View File

@ -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<typeof createHostedReviewSlice>))
}))
}
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<typeof review>((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<typeof updatedReview>((resolve) => {
resolveRefresh = resolve
})
mockApi.hostedReview.forBranch
.mockResolvedValueOnce(review)
.mockReturnValueOnce(refresh as Promise<HostedReviewInfo>)
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)
})
})

View File

@ -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<Pick<AppState, 'hostedReviewCache' | 'fetchHostedReviewForBranch' | 'settings'>>()(
(...args) => ({
settings,
...createHostedReviewSlice(...(args as Parameters<typeof createHostedReviewSlice>))
})
)
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<typeof createHostedReviewSlice>))
}))
}
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<typeof review>((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<typeof updatedReview>((resolve) => {
resolveRefresh = resolve
})
mockApi.hostedReview.forBranch
.mockResolvedValueOnce(review)
.mockReturnValueOnce(refresh as Promise<HostedReviewInfo>)
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)
})
})

View File

@ -134,12 +134,16 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
const target = getActiveRuntimeTarget(settings)
if (target.kind === 'environment') {
const repo = get().repos.find((candidate) => candidate.path === args.repoPath)
const { repoPath: _repoPath, ...runtimeArgs } = args
const { repoPath: _repoPath, worktreePath, ...runtimeArgs } = args
void _repoPath
return callRuntimeRpc<HostedReviewCreationEligibility>(
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<AppState, [], [], HostedRevie
const target = getActiveRuntimeTarget(settings)
if (target.kind === 'environment') {
const repo = get().repos.find((candidate) => candidate.path === repoPath)
const { worktreePath, ...runtimeInput } = input
return callRuntimeRpc<CreateHostedReviewResult>(
target,
'hostedReview.create',
{ repo: repo?.id ?? repoPath, ...input },
{
repo: repo?.id ?? repoPath,
...(worktreePath ? { worktree: `path:${worktreePath}` } : {}),
...runtimeInput
},
{ timeoutMs: 60_000 }
)
}

View File

@ -553,6 +553,11 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['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),

View File

@ -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

View File

@ -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
})
})
})

View File

@ -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<PullRequestDraftContext, 'base' | 'currentTitle' | 'currentBody' | 'currentDraft'>
): 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<string, unknown>
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
}
}