diff --git a/src/main/github/client.ts b/src/main/github/client.ts index da0a87d9c50..8a1c1b3d891 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -238,6 +238,31 @@ export async function getPRChecks( } } +/** + * Merge a PR by number using gh CLI. + * method: 'merge' | 'squash' | 'rebase' (default: 'squash') + */ +export async function mergePR( + repoPath: string, + prNumber: number, + method: 'merge' | 'squash' | 'rebase' = 'squash' +): Promise<{ ok: true } | { ok: false; error: string }> { + await acquire() + try { + await execFileAsync('gh', ['pr', 'merge', String(prNumber), `--${method}`, '--delete-branch'], { + cwd: repoPath, + encoding: 'utf-8' + }) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: message } + } finally { + release() + } +} + /** * Update a PR's title. */ diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index aad24e80781..39e17b001df 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -1,7 +1,14 @@ import { ipcMain } from 'electron' import { resolve } from 'path' import type { Store } from '../persistence' -import { getPRForBranch, getIssue, listIssues, getPRChecks, updatePRTitle } from '../github/client' +import { + getPRForBranch, + getIssue, + listIssues, + getPRChecks, + updatePRTitle, + mergePR +} from '../github/client' function assertRegisteredRepoPath(repoPath: string, store: Store): string { const resolvedRepoPath = resolve(repoPath) @@ -43,4 +50,15 @@ export function registerGitHubHandlers(store: Store): void { return updatePRTitle(repoPath, args.prNumber, args.title) } ) + + ipcMain.handle( + 'gh:mergePR', + ( + _event, + args: { repoPath: string; prNumber: number; method?: 'merge' | 'squash' | 'rebase' } + ) => { + const repoPath = assertRegisteredRepoPath(args.repoPath, store) + return mergePR(repoPath, args.prNumber, args.method) + } + ) } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index fc6a5dea480..9f742fb94a4 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -61,6 +61,11 @@ type GhApi = { branch?: string }) => Promise updatePRTitle: (args: { repoPath: string; prNumber: number; title: string }) => Promise + mergePR: (args: { + repoPath: string + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + }) => Promise<{ ok: true } | { ok: false; error: string }> } type SettingsApi = { diff --git a/src/preload/index.ts b/src/preload/index.ts index d2ced6fafbd..0d9de9e29c6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -149,7 +149,14 @@ const api = { repoPath: string prNumber: number title: string - }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args) + }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), + + mergePR: (args: { + repoPath: string + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:mergePR', args) }, settings: { diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 8d1ef25de5f..0bc20f05b55 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -13,6 +13,7 @@ import { } from 'lucide-react' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' +import PRActions from './PRActions' import type { PRInfo, PRCheckDetail } from '../../../../shared/types' function PullRequestIcon({ className }: { className?: string }): React.JSX.Element { @@ -213,6 +214,13 @@ export default function ChecksPanel(): React.JSX.Element { [handleSaveTitle, handleCancelEdit] ) + // Refresh PR (passed to PRActions) + const handleRefreshPR = useCallback(async () => { + if (repo && branch) { + await fetchPRForBranch(repo.path, branch) + } + }, [repo, branch, fetchPRForBranch]) + // Open PR in browser const handleOpenPR = useCallback(() => { if (pr?.url) { @@ -348,6 +356,11 @@ export default function ChecksPanel(): React.JSX.Element { Updated {new Date(pr.updatedAt).toLocaleString()} )} + + {/* Merge / Delete Worktree actions */} + {worktree && repo && ( + + )} {/* Checks Summary */} diff --git a/src/renderer/src/components/right-sidebar/PRActions.tsx b/src/renderer/src/components/right-sidebar/PRActions.tsx new file mode 100644 index 00000000000..d2db7787eb1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/PRActions.tsx @@ -0,0 +1,140 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { LoaderCircle, GitMerge, ChevronDown, Trash2 } from 'lucide-react' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import type { PRInfo, Repo, Worktree } from '../../../../shared/types' + +const MERGE_METHODS = ['squash', 'merge', 'rebase'] as const + +const MERGE_LABELS: Record<(typeof MERGE_METHODS)[number], string> = { + squash: 'Squash and merge', + merge: 'Create a merge commit', + rebase: 'Rebase and merge' +} + +export default function PRActions({ + pr, + repo, + worktree, + onRefreshPR +}: { + pr: PRInfo + repo: Repo + worktree: Worktree + onRefreshPR: () => Promise +}): React.JSX.Element | null { + const openModal = useAppStore((s) => s.openModal) + const [merging, setMerging] = useState(false) + const [mergeError, setMergeError] = useState(null) + const [mergeMenuOpen, setMergeMenuOpen] = useState(false) + const mergeMenuRef = useRef(null) + + const handleMerge = useCallback( + async (method: 'merge' | 'squash' | 'rebase' = 'squash') => { + setMerging(true) + setMergeError(null) + setMergeMenuOpen(false) + try { + const result = await window.api.gh.mergePR({ + repoPath: repo.path, + prNumber: pr.number, + method + }) + if (!result.ok) { + setMergeError(result.error) + } else { + await onRefreshPR() + } + } catch (err) { + setMergeError(err instanceof Error ? err.message : 'Merge failed') + } finally { + setMerging(false) + } + }, + [repo.path, pr.number, onRefreshPR] + ) + + useEffect(() => { + if (!mergeMenuOpen) { + return + } + const handleClickOutside = (e: MouseEvent): void => { + if (mergeMenuRef.current && !mergeMenuRef.current.contains(e.target as Node)) { + setMergeMenuOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [mergeMenuOpen]) + + const handleDeleteWorktree = useCallback(() => { + openModal('delete-worktree', { worktreeId: worktree.id }) + }, [worktree.id, openModal]) + + if (pr.state === 'open') { + return ( +
+
+ + + {mergeMenuOpen && ( +
+ {MERGE_METHODS.map((method) => ( + + ))} +
+ )} +
+ {mergeError &&
{mergeError}
} +
+ ) + } + + if (pr.state === 'merged') { + return ( + + ) + } + + return null +}