From b96e232e431c4cfc91bdf61fb7599d9b911465ea Mon Sep 17 00:00:00 2001 From: Styrse Date: Wed, 12 Aug 2026 22:35:12 +0200 Subject: [PATCH] Add PR creation action to pane git controls --- frontend/src/components/SessionView.tsx | 28 +++++++- frontend/src/hooks/useSessionView.ts | 31 ++++++++ frontend/src/types/electron.d.ts | 1 + frontend/src/utils/api.ts | 5 ++ main/src/ipc/daemonRegistryBindings.test.ts | 1 + main/src/ipc/git.ts | 80 +++++++++++++++++++++ main/src/preload.ts | 2 +- main/src/services/worktreeManager.ts | 32 +++++++++ 8 files changed, 178 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index fcc03f07..c920cfd7 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -1620,6 +1620,32 @@ export const SessionView = memo(() => { : 'No commits to push', disabledReason: busyReason ?? (activeSession.gitStatus?.ahead ? undefined : 'No commits to push'), }, + { + id: 'create-pr', + label: 'PR', + icon: GitPullRequestArrow, + onClick: hook.handleCreatePr, + disabled: hook.isMerging || activeSession.status === 'running' || activeSession.status === 'initializing' || + Boolean(activeSession.gitStatus?.prUrl) || + Boolean(activeSession.gitStatus?.ahead && activeSession.gitStatus.ahead > 0) || + !activeSession.gitStatus?.totalCommits, + variant: 'default' as const, + description: activeSession.gitStatus?.prUrl + ? `Pull request already exists: ${activeSession.gitStatus.prUrl}` + : activeSession.gitStatus?.ahead && activeSession.gitStatus.ahead > 0 + ? 'Push this branch before creating a pull request' + : activeSession.gitStatus?.totalCommits + ? `Create a pull request from ${hook.gitCommands?.currentBranch || 'current branch'}` + : 'No commits for a pull request', + disabledReason: busyReason ?? + (activeSession.gitStatus?.prUrl + ? 'Pull request already exists' + : activeSession.gitStatus?.ahead && activeSession.gitStatus.ahead > 0 + ? 'Push branch first' + : activeSession.gitStatus?.totalCommits + ? undefined + : 'No commits for a pull request'), + }, // --- Main branch operations (last) --- { id: 'rebase-from-main', @@ -1647,7 +1673,7 @@ export const SessionView = memo(() => { disabledReason: busyReason ?? ((!activeSession.gitStatus?.totalCommits || activeSession.gitStatus?.totalCommits === 0 || activeSession.gitStatus?.ahead === 0) ? 'No commits to merge' : undefined), } ]; - }, [activeSession, hook.isMerging, hook.gitCommands, hook.hasChangesToRebase, hook.hasStash, hook.handleGitPull, hook.handleGitPush, hook.handleGitSoftReset, hook.handleGitFetch, hook.handleGitStash, hook.handleGitStashPop, hook.setShowCommitMessageDialog, hook.setDialogType, hook.handleRebaseMainIntoWorktree, hook.handleSquashAndRebaseToMain, activeSession?.gitStatus]); + }, [activeSession, hook.isMerging, hook.gitCommands, hook.hasChangesToRebase, hook.hasStash, hook.handleGitPull, hook.handleGitPush, hook.handleCreatePr, hook.handleGitSoftReset, hook.handleGitFetch, hook.handleGitStash, hook.handleGitStashPop, hook.setShowCommitMessageDialog, hook.setDialogType, hook.handleRebaseMainIntoWorktree, hook.handleSquashAndRebaseToMain, activeSession?.gitStatus]); // Removed unused variables - now handled by panels diff --git a/frontend/src/hooks/useSessionView.ts b/frontend/src/hooks/useSessionView.ts index 1098ae44..7341f7d5 100644 --- a/frontend/src/hooks/useSessionView.ts +++ b/frontend/src/hooks/useSessionView.ts @@ -22,6 +22,15 @@ interface PromptMarker { completion_timestamp?: string; } +interface CreatePrResponseData { + url?: string; +} + +function isCreatePrResponseData(value: unknown): value is CreatePrResponseData { + return typeof value === 'object' && + value !== null && + (!('url' in value) || typeof value.url === 'string'); +} export const useSessionView = ( activeSession: Session | undefined, @@ -964,6 +973,27 @@ export const useSessionView = ( } }; + const handleCreatePr = async () => { + if (!activeSession) return; + setIsMerging(true); + setMergeError(null); + try { + const response = await API.sessions.createPr(activeSession.id); + if (!response.success) { + setMergeError(response.error || 'Failed to create pull request'); + return; + } + + if (isCreatePrResponseData(response.data) && response.data.url) { + await window.electronAPI?.openExternal(response.data.url); + } + } catch (error) { + setMergeError(error instanceof Error ? error.message : 'Failed to create pull request'); + } finally { + setIsMerging(false); + } + }; + const handleGitSoftReset = async () => { if (!activeSession) return; setIsMerging(true); @@ -1558,6 +1588,7 @@ export const useSessionView = ( handleStopSession, handleGitPull, handleGitPush, + handleCreatePr, handleGitSoftReset, handleGitFetch, handleGitStash, diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 52281e2a..2cdb9cf7 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -158,6 +158,7 @@ interface ElectronAPI { // Git pull/push operations gitPull: (sessionId: string) => Promise; gitPush: (sessionId: string) => Promise; + createPr: (sessionId: string) => Promise; gitFetch: (sessionId: string) => Promise; gitStash: (sessionId: string, message?: string) => Promise; gitStashPop: (sessionId: string) => Promise; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 6da34626..dcc3457c 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -306,6 +306,11 @@ export class API { return window.electronAPI.sessions.gitPush(sessionId); }, + async createPr(sessionId: string) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.sessions.createPr(sessionId); + }, + async gitFetch(sessionId: string) { if (!isElectron()) throw new Error('Electron API not available'); return window.electronAPI.sessions.gitFetch(sessionId); diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts index b2291bc3..4de3e1cc 100644 --- a/main/src/ipc/daemonRegistryBindings.test.ts +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -218,6 +218,7 @@ const GIT_MUTATION_CHANNELS = [ 'sessions:rebase-to-main', 'sessions:git-pull', 'sessions:git-push', + 'sessions:create-pr', 'sessions:git-soft-reset', 'sessions:git-fetch', 'sessions:git-stash', diff --git a/main/src/ipc/git.ts b/main/src/ipc/git.ts index 815d8f5a..11b70fd6 100644 --- a/main/src/ipc/git.ts +++ b/main/src/ipc/git.ts @@ -93,6 +93,7 @@ const DAEMON_GIT_MUTATION_CHANNELS = [ 'sessions:rebase-to-main', 'sessions:git-pull', 'sessions:git-push', + 'sessions:create-pr', 'sessions:git-soft-reset', 'sessions:git-fetch', 'sessions:git-stash', @@ -1431,6 +1432,85 @@ export function registerGitHandlers( } }); + commandRegistry.register('sessions:create-pr', async (sessionId: string) => { + try { + const session = await sessionManager.getSession(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + + if (!session.worktreePath) { + return { success: false, error: 'Session has no worktree path' }; + } + + const ctx = sessionManager.getProjectContext(sessionId); + if (!ctx) throw new Error('Project context not found for session'); + + const currentBranch = ctx.commandRunner.exec('git branch --show-current', session.worktreePath).trim(); + if (!currentBranch) { + return { success: false, error: 'Cannot create a pull request from a detached HEAD' }; + } + + const comparisonBranch = await worktreeManager.getSessionComparisonBranch(session, ctx); + + const startMessage = `🔄 GIT OPERATION\nCreating pull request...`; + emitGitOperationToProject(sessionId, 'git:operation_started', startMessage, { + operation: 'create-pr', + branch: currentBranch, + base: comparisonBranch, + }); + + const result = await worktreeManager.createPullRequest( + session.worktreePath, + comparisonBranch, + currentBranch, + ctx.commandRunner, + ); + + const successMessage = `✓ Successfully created pull request` + + (result.url ? `\n\n${result.url}` : '') + + (result.output ? `\n\nGitHub CLI output:\n${result.output}` : ''); + emitGitOperationToProject(sessionId, 'git:operation_completed', successMessage, { + operation: 'create-pr', + output: result.output, + url: result.url, + }); + sessionManager.addSessionOutput(sessionId, { + type: 'stdout', + data: successMessage, + timestamp: new Date() + }); + + const project = sessionManager.getProjectForSession(sessionId); + if (project?.path) { + gitStatusManager.invalidatePrCache(project.path); + } + await refreshGitStatusForSession(sessionId); + + return { success: true, data: result }; + } catch (error: unknown) { + console.error('Failed to create pull request:', error); + + const gitError = error as GitError; + const errorMessage = `✗ Create PR failed: ${error instanceof Error ? error.message : 'Unknown error'}` + + (gitError.gitOutput ? `\n\nGitHub CLI output:\n${gitError.gitOutput}` : ''); + emitGitOperationToProject(sessionId, 'git:operation_failed', errorMessage, { + operation: 'create-pr', + error: error instanceof Error ? error.message : String(error), + gitOutput: gitError.gitOutput + }); + + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create pull request', + gitError: { + output: gitError.gitOutput || (error instanceof Error ? error.message : String(error)), + workingDirectory: gitError.workingDirectory || '' + } + }; + } + }); + commandRegistry.register('sessions:git-soft-reset', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); diff --git a/main/src/preload.ts b/main/src/preload.ts index 4ce5c9ad..3924e8f5 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -172,7 +172,6 @@ const DAEMON_OWNED_EXACT_CHANNELS = [ const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ 'file:showInFolder', 'sessions:open-ide', - 'sessions:set-active-session', 'terminal:clipboard-paste-image', ]); @@ -510,6 +509,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // Git pull/push operations gitPull: (sessionId: string): Promise => invokeIpc('sessions:git-pull', sessionId), gitPush: (sessionId: string): Promise => invokeIpc('sessions:git-push', sessionId), + createPr: (sessionId: string): Promise => invokeIpc('sessions:create-pr', sessionId), gitFetch: (sessionId: string): Promise => invokeIpc('sessions:git-fetch', sessionId), gitStash: (sessionId: string, message?: string): Promise => invokeIpc('sessions:git-stash', sessionId, message), gitStashPop: (sessionId: string): Promise => invokeIpc('sessions:git-stash-pop', sessionId), diff --git a/main/src/services/worktreeManager.ts b/main/src/services/worktreeManager.ts index bd8e32ae..3e939abb 100644 --- a/main/src/services/worktreeManager.ts +++ b/main/src/services/worktreeManager.ts @@ -1282,6 +1282,38 @@ Co-Authored-By: Pane ` : commitMessage; } } + async createPullRequest( + worktreePath: string, + baseBranch: string, + currentBranch: string, + commandRunner: CommandRunner, + ): Promise<{ output: string; url?: string }> { + try { + const normalizedBaseBranch = baseBranch.replace(/^origin\//, ''); + const command = [ + 'gh pr create', + '--fill', + `--base ${escapeShellArg(normalizedBaseBranch)}`, + `--head ${escapeShellArg(currentBranch)}`, + ].join(' '); + + const { stdout, stderr } = await commandRunner.execAsync(command, worktreePath, { timeout: 120000 }); + const output = stdout || stderr || 'Pull request created successfully'; + const url = output.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/)?.[0]; + + return { output, url }; + } catch (error: unknown) { + const err = error as Error & { stderr?: string; stdout?: string }; + const gitError = new Error(err.message || 'Failed to create pull request') as Error & { + gitOutput?: string; + workingDirectory?: string; + }; + gitError.gitOutput = err.stderr || err.stdout || err.message || ''; + gitError.workingDirectory = worktreePath; + throw gitError; + } + } + async gitFetch(worktreePath: string, commandRunner: CommandRunner): Promise<{ output: string }> { try { const { stdout, stderr } = await commandRunner.execAsync('git fetch --all', worktreePath, { timeout: 30000 });