Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion frontend/src/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions frontend/src/hooks/useSessionView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1558,6 +1588,7 @@ export const useSessionView = (
handleStopSession,
handleGitPull,
handleGitPush,
handleCreatePr,
handleGitSoftReset,
handleGitFetch,
handleGitStash,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ interface ElectronAPI {
// Git pull/push operations
gitPull: (sessionId: string) => Promise<IPCResponse>;
gitPush: (sessionId: string) => Promise<IPCResponse>;
createPr: (sessionId: string) => Promise<IPCResponse>;
gitFetch: (sessionId: string) => Promise<IPCResponse>;
gitStash: (sessionId: string, message?: string) => Promise<IPCResponse>;
gitStashPop: (sessionId: string) => Promise<IPCResponse>;
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions main/src/ipc/daemonRegistryBindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
80 changes: 80 additions & 0 deletions main/src/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion main/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@ const DAEMON_OWNED_EXACT_CHANNELS = [
const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set<string>([
'file:showInFolder',
'sessions:open-ide',
'sessions:set-active-session',
'terminal:clipboard-paste-image',
]);

Expand Down Expand Up @@ -510,6 +509,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Git pull/push operations
gitPull: (sessionId: string): Promise<IPCResponse> => invokeIpc('sessions:git-pull', sessionId),
gitPush: (sessionId: string): Promise<IPCResponse> => invokeIpc('sessions:git-push', sessionId),
createPr: (sessionId: string): Promise<IPCResponse> => invokeIpc('sessions:create-pr', sessionId),
gitFetch: (sessionId: string): Promise<IPCResponse> => invokeIpc('sessions:git-fetch', sessionId),
gitStash: (sessionId: string, message?: string): Promise<IPCResponse> => invokeIpc('sessions:git-stash', sessionId, message),
gitStashPop: (sessionId: string): Promise<IPCResponse> => invokeIpc('sessions:git-stash-pop', sessionId),
Expand Down
32 changes: 32 additions & 0 deletions main/src/services/worktreeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,38 @@ Co-Authored-By: Pane <runpane@users.noreply.github.com>` : 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 });
Expand Down