From ed07452c80e3ee4d11a008ca38ef6de94222dfcc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 9 May 2026 15:39:26 +0800 Subject: [PATCH] feat(checkout): merge PR action with real-GitHub e2e test (#814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(checkout): merge PR action with real-GitHub e2e test Adds a `checkoutPrMerge` server RPC backed by `gh pr merge` (squash, merge, or rebase) and surfaces three new actions in the git-actions menu — "Squash and merge", "Create a merge commit", "Rebase and merge" — with squash taking the primary slot when a PR is open and mergeable. Also renames the existing local merge action to "Merge locally" to disambiguate it from the new GitHub-side merge. Adds an end-to-end test that creates a real temporary GitHub repo, opens a PR via the daemon, polls until mergeable, calls `checkoutPrMerge`, and verifies the merge landed on `main` — no mocks. The test pre-flights the `delete_repo` scope so it skips cleanly on tokens that can't tear down, and fails loud if cleanup ever errors so we don't leak repos. PR status gains an optional `mergeable` field (`MERGEABLE | CONFLICTING | UNKNOWN`) populated from `gh pr view --json mergeable`. The schema is backward compatible — old payloads without the field still parse and default to `UNKNOWN` via `z.catch`. * Update PR status snapshot expectations * refactor: collect git/checkout/PR feature into packages/app/src/git/ Move 21 git/checkout/PR files out of components/, hooks/, stores/, screens/, utils/ flat-peer slop into a single packages/app/src/git/ module with shrunk filenames (git-diff-pane.tsx -> diff-pane.tsx, use-git-actions.ts -> use-actions.ts, etc). Dedupe: drop the duplicate useGitActionHandlers from diff-pane.tsx — the shared useGitActions hook is the single source. Drop the buildGitActionsForPane wrapper. Collapse the merge-PR triple fan-out (mergePrSquash/Merge/RebaseStatus) into one record keyed by CheckoutPrMergeMethod. Streamline server-side single-use helpers (dispatchStash/PullRequestMessage adapters, GitHubPullRequestMergeError wrapper, legacy worktree test helpers). Schema-additive only: messages.ts exports CheckoutPrMergeMethod and PullRequestMergeable types; daemon-client.ts uses the typed alias. Backward-compatible. Net delta: 43 files, +219/-1212. * fix(app): use GitHub icon for all merge-PR menu actions * test: namespace temp GitHub repos under `paseotmp-` and centralize naming Old prefixes (`paseo-checkout-ship-`, `paseo-e2e-`, `paseo-checkout-pr-merge-`) collided with the `paseo-*` namespace of real repos, making bulk cleanup unsafe. Switch every test that creates real GitHub repos to a single unmistakable `paseotmp-` prefix, owned by one shared helper per test boundary so no caller can name a repo outside the namespace. - New `packages/server/src/server/test-utils/temp-github-repo.ts` exports `TEMP_GITHUB_REPO_PREFIX` + `createTempGithubRepoName(category)`; both server e2e tests (checkout-ship, checkout-pr-merge) consume it. - `packages/app/e2e/helpers/github-fixtures.ts` locks the prefix internally and now requires callers to pass a `category` instead of an arbitrary `prefix`. Updated pr-pane and composer-attachments specs accordingly. --- packages/app/e2e/composer-attachments.spec.ts | 4 +- packages/app/e2e/helpers/github-fixtures.ts | 11 +- packages/app/e2e/helpers/pr-pane.ts | 2 +- packages/app/e2e/pr-pane.spec.ts | 2 +- .../app/src/components/explorer-sidebar.tsx | 4 +- .../src/components/sidebar-workspace-list.tsx | 4 +- .../src/components/workspace-hover-card.tsx | 2 +- .../actions-split-button.tsx} | 6 +- .../actions-store.test.ts} | 2 +- .../actions-store.ts} | 24 + .../diff-order.test.ts} | 2 +- .../diff-order.ts} | 0 .../git-diff-pane.tsx => git/diff-pane.tsx} | 741 +----------------- .../diff-scroll.test.ts} | 2 +- .../git-diff-scroll.ts => git/diff-scroll.ts} | 0 .../github-url.test.ts} | 2 +- .../github-repo-url.ts => git/github-url.ts} | 0 .../policy.test.ts} | 203 ++++- .../git-actions-policy.ts => git/policy.ts} | 125 ++- .../src/{utils => git}/pr-pane-data.test.ts | 1 + .../app/src/{utils => git}/pr-pane-data.ts | 0 .../app/src/{components => git}/pr-pane.tsx | 4 +- .../src/{utils => git}/pr-tab-label.test.ts | 0 .../app/src/{utils => git}/pr-tab-label.ts | 0 .../use-git-actions.ts => git/use-actions.ts} | 237 +++--- .../use-diff-query.ts} | 2 +- .../use-pr-status-query.ts} | 0 .../use-status-query.test.ts} | 2 +- .../use-status-query.ts} | 0 .../workspace-actions.tsx} | 7 +- packages/app/src/hooks/use-branch-switcher.ts | 2 +- .../app/src/hooks/use-pr-pane-data.test.ts | 3 +- packages/app/src/hooks/use-pr-pane-data.ts | 4 +- packages/app/src/review/store.test.ts | 2 +- packages/app/src/review/store.ts | 2 +- .../workspace-open-in-editor-button.tsx | 4 +- .../screens/workspace/workspace-screen.tsx | 7 +- packages/app/src/utils/diff-layout.test.ts | 2 +- packages/app/src/utils/diff-layout.ts | 2 +- .../app/src/utils/new-agent-routing.test.ts | 2 +- packages/app/src/utils/new-agent-routing.ts | 2 +- packages/server/src/client/daemon-client.ts | 20 + .../src/server/agent/mcp-server.test.ts | 1 + .../daemon-e2e/checkout-pr-merge.e2e.test.ts | 318 ++++++++ .../daemon-e2e/checkout-ship.e2e.test.ts | 13 +- .../src/server/paseo-worktree-service.test.ts | 1 + .../resolve-worktree-creation-intent.test.ts | 1 + packages/server/src/server/session.test.ts | 89 +++ packages/server/src/server/session.ts | 46 ++ .../session.workspace-git-watch.test.ts | 2 + .../server/src/server/test-utils/index.ts | 1 + .../src/server/test-utils/temp-github-repo.ts | 9 + .../workspace-git-service.primitive.test.ts | 1 + .../src/server/workspace-git-service.test.ts | 1 + .../src/server/workspace-git-service.ts | 7 +- .../src/server/worktree-core.posix.test.ts | 1 + .../src/server/worktree-session.test.ts | 1 + .../src/services/github-service.test.ts | 68 +- .../server/src/services/github-service.ts | 32 +- .../messages.checkout-pr-schema.test.ts | 47 ++ packages/server/src/shared/messages.ts | 28 + .../server/src/utils/checkout-git.test.ts | 1 + packages/server/src/utils/checkout-git.ts | 2 + 63 files changed, 1204 insertions(+), 907 deletions(-) rename packages/app/src/{components/git-actions-split-button.tsx => git/actions-split-button.tsx} (97%) rename packages/app/src/{stores/checkout-git-actions-store.test.ts => git/actions-store.test.ts} (99%) rename packages/app/src/{stores/checkout-git-actions-store.ts => git/actions-store.ts} (95%) rename packages/app/src/{hooks/checkout-diff-order.test.ts => git/diff-order.test.ts} (97%) rename packages/app/src/{hooks/checkout-diff-order.ts => git/diff-order.ts} (100%) rename packages/app/src/{components/git-diff-pane.tsx => git/diff-pane.tsx} (78%) rename packages/app/src/{utils/git-diff-scroll.test.ts => git/diff-scroll.test.ts} (95%) rename packages/app/src/{utils/git-diff-scroll.ts => git/diff-scroll.ts} (100%) rename packages/app/src/{utils/github-repo-url.test.ts => git/github-url.test.ts} (98%) rename packages/app/src/{utils/github-repo-url.ts => git/github-url.ts} (100%) rename packages/app/src/{components/git-actions-policy.test.ts => git/policy.test.ts} (51%) rename packages/app/src/{components/git-actions-policy.ts => git/policy.ts} (71%) rename packages/app/src/{utils => git}/pr-pane-data.test.ts (99%) rename packages/app/src/{utils => git}/pr-pane-data.ts (100%) rename packages/app/src/{components => git}/pr-pane.tsx (99%) rename packages/app/src/{utils => git}/pr-tab-label.test.ts (100%) rename packages/app/src/{utils => git}/pr-tab-label.ts (100%) rename packages/app/src/{hooks/use-git-actions.ts => git/use-actions.ts} (72%) rename packages/app/src/{hooks/use-checkout-diff-query.ts => git/use-diff-query.ts} (98%) rename packages/app/src/{hooks/use-checkout-pr-status-query.ts => git/use-pr-status-query.ts} (100%) rename packages/app/src/{hooks/use-checkout-status-query.test.ts => git/use-status-query.test.ts} (99%) rename packages/app/src/{hooks/use-checkout-status-query.ts => git/use-status-query.ts} (100%) rename packages/app/src/{screens/workspace/workspace-git-actions.tsx => git/workspace-actions.tsx} (84%) create mode 100644 packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts create mode 100644 packages/server/src/server/test-utils/temp-github-repo.ts create mode 100644 packages/server/src/shared/messages.checkout-pr-schema.test.ts diff --git a/packages/app/e2e/composer-attachments.spec.ts b/packages/app/e2e/composer-attachments.spec.ts index 50cd328eb..a7d20cdfa 100644 --- a/packages/app/e2e/composer-attachments.spec.ts +++ b/packages/app/e2e/composer-attachments.spec.ts @@ -82,7 +82,7 @@ test.describe("Composer attachments", () => { } const ghRepo = await createTempGithubRepo({ - prefix: "paseo-e2e-attach-issue-", + category: "attach-issue", issues: [{ title: "fix: attach-issue-unique-alpha" }], prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }], }); @@ -114,7 +114,7 @@ test.describe("Composer attachments", () => { } const ghRepo = await createTempGithubRepo({ - prefix: "paseo-e2e-attach-pr-", + category: "attach-pr", prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }], }); const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath); diff --git a/packages/app/e2e/helpers/github-fixtures.ts b/packages/app/e2e/helpers/github-fixtures.ts index 7e0b36860..67dbfd22a 100644 --- a/packages/app/e2e/helpers/github-fixtures.ts +++ b/packages/app/e2e/helpers/github-fixtures.ts @@ -153,14 +153,19 @@ function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture return { number: issueNumber, title: spec.title, url: issueUrl }; } +// Single namespace for temporary GitHub repos created by Paseo tests. +// Bulk cleanup relies on this prefix being unmistakable — never reuse `paseo-` +// (collides with real repos like `paseo`, `paseo-website`). +const TEMP_GITHUB_REPO_PREFIX = "paseotmp-"; + export async function createTempGithubRepo(options: { - prefix?: string; + category: string; prs?: PrSpec[]; issues?: IssueSpec[]; }): Promise { - const { prefix = "paseo-e2e-", prs = [], issues = [] } = options; + const { category, prs = [], issues = [] } = options; const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - const repoName = `${prefix}${uniqueSuffix}`; + const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`; // Bootstrap local git repo const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`)); diff --git a/packages/app/e2e/helpers/pr-pane.ts b/packages/app/e2e/helpers/pr-pane.ts index 924f12208..ce93beeae 100644 --- a/packages/app/e2e/helpers/pr-pane.ts +++ b/packages/app/e2e/helpers/pr-pane.ts @@ -1,5 +1,5 @@ import { expect, type Page } from "@playwright/test"; -import { getStateLabel } from "@/utils/pr-pane-data"; +import { getStateLabel } from "@/git/pr-pane-data"; export async function openPrPane(page: Page): Promise { await page.getByRole("button", { name: "Open explorer" }).click(); diff --git a/packages/app/e2e/pr-pane.spec.ts b/packages/app/e2e/pr-pane.spec.ts index b0106f18e..2ac00b577 100644 --- a/packages/app/e2e/pr-pane.spec.ts +++ b/packages/app/e2e/pr-pane.spec.ts @@ -28,7 +28,7 @@ test.describe("PR pane", () => { seedClient = await connectWorkspaceSetupClient(); repoFixture = await createTempGithubRepo({ - prefix: "paseo-e2e-pr-", + category: "pr-pane", prs: [ { title: "Review selected start ref", state: "open" }, { title: "Merged feature branch", state: "merged" }, diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index b4759cada..086fd2cc9 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -13,7 +13,7 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; import { GitHubIcon } from "@/components/icons/github-icon"; -import { PrPane } from "./pr-pane"; +import { PrPane } from "@/git/pr-pane"; import { usePrPaneData } from "@/hooks/use-pr-pane-data"; import { usePanelStore, @@ -24,7 +24,7 @@ import { } from "@/stores/panel-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; -import { GitDiffPane } from "./git-diff-pane"; +import { GitDiffPane } from "@/git/diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useWindowControlsPadding } from "@/utils/desktop-window"; diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 294f86abb..12f588c83 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -81,7 +81,7 @@ import { } from "@/components/ui/dropdown-menu"; import { SyncedLoader } from "@/components/synced-loader"; import { useToast } from "@/contexts/toast-context"; -import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; +import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -95,7 +95,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; +import { type PrHint, useWorkspacePrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index d2f03b3cf..672f5c808 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -27,7 +27,7 @@ import { Pressable } from "react-native"; import { Portal } from "@gorhom/portal"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; -import type { PrHint } from "@/hooks/use-checkout-pr-status-query"; +import type { PrHint } from "@/git/use-pr-status-query"; import { openExternalUrl } from "@/utils/open-external-url"; import { PrBadge } from "@/components/sidebar-workspace-list"; import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; diff --git a/packages/app/src/components/git-actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx similarity index 97% rename from packages/app/src/components/git-actions-split-button.tsx rename to packages/app/src/git/actions-split-button.tsx index 5113a3f65..34f988edf 100644 --- a/packages/app/src/components/git-actions-split-button.tsx +++ b/packages/app/src/git/actions-split-button.tsx @@ -19,7 +19,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useToast } from "@/contexts/toast-context"; -import type { GitAction, GitActions } from "@/components/git-actions-policy"; +import type { GitAction, GitActions } from "@/git/policy"; interface GitActionsSplitButtonProps { gitActions: GitActions; @@ -163,9 +163,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli action={action} onSelect={handleActionSelect} archiveShortcutKeys={archiveShortcutKeys} - needsSeparator={ - action.id === "merge-from-base" || action.id === "archive-worktree" - } + needsSeparator={action.startsGroup} showSeparator={index > 0} closeOnSelect={ action.status === "idle" && action.id === "pr" && action.label === "View PR" diff --git a/packages/app/src/stores/checkout-git-actions-store.test.ts b/packages/app/src/git/actions-store.test.ts similarity index 99% rename from packages/app/src/stores/checkout-git-actions-store.test.ts rename to packages/app/src/git/actions-store.test.ts index 5212e74ce..ebdc654dd 100644 --- a/packages/app/src/stores/checkout-git-actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -9,7 +9,7 @@ import { invalidateCheckoutGitQueriesForClient, isLocalWorktreeArchivePending, useCheckoutGitActionsStore, -} from "@/stores/checkout-git-actions-store"; +} from "@/git/actions-store"; import { clearWorkspaceArchivePending, isWorkspaceArchivePending, diff --git a/packages/app/src/stores/checkout-git-actions-store.ts b/packages/app/src/git/actions-store.ts similarity index 95% rename from packages/app/src/stores/checkout-git-actions-store.ts rename to packages/app/src/git/actions-store.ts index d06a847f4..5dd128081 100644 --- a/packages/app/src/stores/checkout-git-actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -1,4 +1,5 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; +import type { CheckoutPrMergeMethod } from "@server/shared/messages"; import { create } from "zustand"; import { queryClient as appQueryClient } from "@/query/query-client"; import { @@ -27,6 +28,9 @@ export type CheckoutGitAsyncActionId = | "push" | "pull-and-push" | "create-pr" + | "merge-pr-squash" + | "merge-pr-merge" + | "merge-pr-rebase" | "merge-branch" | "merge-from-base" | "archive-worktree"; @@ -259,6 +263,11 @@ interface CheckoutGitActionsStoreState { push: (params: { serverId: string; cwd: string }) => Promise; pullAndPush: (params: { serverId: string; cwd: string }) => Promise; createPr: (params: { serverId: string; cwd: string }) => Promise; + mergePr: (params: { + serverId: string; + cwd: string; + method: CheckoutPrMergeMethod; + }) => Promise; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; archiveWorktree: (params: { @@ -405,6 +414,21 @@ export const useCheckoutGitActionsStore = create() }); }, + mergePr: async ({ serverId, cwd, method }) => { + await runCheckoutAction({ + serverId, + cwd, + actionId: `merge-pr-${method}`, + run: async () => { + const client = resolveClient(serverId); + const payload = await client.checkoutPrMerge(cwd, { method }); + if (payload.error) { + throw new Error(payload.error.message); + } + }, + }); + }, + mergeBranch: async ({ serverId, cwd, baseRef }) => { await runCheckoutAction({ serverId, diff --git a/packages/app/src/hooks/checkout-diff-order.test.ts b/packages/app/src/git/diff-order.test.ts similarity index 97% rename from packages/app/src/hooks/checkout-diff-order.test.ts rename to packages/app/src/git/diff-order.test.ts index f00231bf9..921e343b5 100644 --- a/packages/app/src/hooks/checkout-diff-order.test.ts +++ b/packages/app/src/git/diff-order.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./checkout-diff-order"; +import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./diff-order"; function createFile(path: string, additions = 0) { return { diff --git a/packages/app/src/hooks/checkout-diff-order.ts b/packages/app/src/git/diff-order.ts similarity index 100% rename from packages/app/src/hooks/checkout-diff-order.ts rename to packages/app/src/git/diff-order.ts diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/git/diff-pane.tsx similarity index 78% rename from packages/app/src/components/git-diff-pane.tsx rename to packages/app/src/git/diff-pane.tsx index bdcaf8c58..683354a01 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -25,7 +25,6 @@ import { type ViewStyle, type TextStyle, } from "react-native"; -import AsyncStorage from "@react-native-async-storage/async-storage"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { @@ -45,20 +44,16 @@ import { Upload, WrapText, } from "lucide-react-native"; -import { - useCheckoutGitActionsStore, - type CheckoutGitActionStatus, -} from "@/stores/checkout-git-actions-store"; import { useCheckoutDiffQuery, type ParsedDiffFile, type DiffLine, type HighlightToken, -} from "@/hooks/use-checkout-diff-query"; -import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; -import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; +} from "@/git/use-diff-query"; +import { useCheckoutStatusQuery } from "@/git/use-status-query"; +import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; -import { DiffScroll } from "./diff-scroll"; +import { DiffScroll } from "@/components/diff-scroll"; import { darkHighlightColors, lightHighlightColors, @@ -66,7 +61,7 @@ import { } from "@getpaseo/highlight"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; -import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; +import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll"; import { buildSplitDiffRows, buildUnifiedDiffLines, @@ -83,16 +78,12 @@ import { } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { GitHubIcon } from "@/components/icons/github-icon"; -import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; -import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; -import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; -import { openExternalUrl } from "@/utils/open-external-url"; -import { GitActionsSplitButton } from "@/components/git-actions-split-button"; +import { GitActionsSplitButton } from "@/git/actions-split-button"; +import { useGitActions } from "@/git/use-actions"; import { usePanelStore } from "@/stores/panel-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; -import { useToast } from "@/contexts/toast-context"; import { formatDiffContentText, formatDiffGutterText, @@ -120,11 +111,7 @@ import { type InlineReviewActions, } from "@/review"; -export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; - -function openURLInNewTab(url: string): void { - void openExternalUrl(url); -} +export type { GitActionId, GitAction, GitActions } from "@/git/policy"; function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) { return [styles.fileHeader, pressed && styles.fileHeaderPressed]; @@ -1274,16 +1261,6 @@ function getDiffContentLength(file: ParsedDiffFile): number { return contentLength; } -function computeBranchLabel(currentBranch: string | null | undefined, notGit: boolean): string { - if (currentBranch && currentBranch !== "HEAD") { - return currentBranch; - } - if (notGit) { - return "Not a git repository"; - } - return "Unknown"; -} - function computeEmptyMessage( hideWhitespace: boolean, diffMode: "uncommitted" | "base", @@ -1411,194 +1388,6 @@ function DiffBodyContent({ ); } -interface GitActionRunners { - runCommit: (args: { serverId: string; cwd: string }) => Promise; - runPull: (args: { serverId: string; cwd: string }) => Promise; - runPush: (args: { serverId: string; cwd: string }) => Promise; - runPullAndPush: (args: { serverId: string; cwd: string }) => Promise; - runCreatePr: (args: { serverId: string; cwd: string }) => Promise; - runMergeBranch: (args: { serverId: string; cwd: string; baseRef: string }) => Promise; - runMergeFromBase: (args: { serverId: string; cwd: string; baseRef: string }) => Promise; - runArchiveWorktree: (args: { - serverId: string; - cwd: string; - worktreePath: string; - }) => Promise; -} - -interface GitActionHandlersDeps { - serverId: string; - cwd: string; - baseRef: string | undefined; - status: ReturnType["status"]; - runners: GitActionRunners; - persistShipDefault: (next: "merge" | "pr") => Promise; - toastError: (message: string) => void; - toastActionError: (error: unknown, fallback: string) => void; - toastActionSuccess: (message: string) => void; - onMergeBranchSuccess: () => void; - onArchiveSuccess: (targetWorkingDir: string) => void; -} - -interface GitActionHandlers { - handleCommit: () => void; - handlePull: () => void; - handlePush: () => void; - handlePullAndPush: () => void; - handleCreatePr: () => void; - handleMergeBranch: () => void; - handleMergeFromBase: () => void; - handleArchiveWorktree: () => void; -} - -function useGitActionHandlers({ - serverId, - cwd, - baseRef, - status, - runners, - persistShipDefault, - toastError, - toastActionError, - toastActionSuccess, - onMergeBranchSuccess, - onArchiveSuccess, -}: GitActionHandlersDeps): GitActionHandlers { - const handleCommit = useCallback(() => { - void runners - .runCommit({ serverId, cwd }) - .then(() => { - toastActionSuccess("Committed"); - return; - }) - .catch((err) => { - toastActionError(err, "Failed to commit"); - }); - }, [cwd, runners, serverId, toastActionError, toastActionSuccess]); - - const handlePull = useCallback(() => { - void runners - .runPull({ serverId, cwd }) - .then(() => { - toastActionSuccess("Pulled"); - return; - }) - .catch((err) => { - toastActionError(err, "Failed to pull"); - }); - }, [cwd, runners, serverId, toastActionError, toastActionSuccess]); - - const handlePush = useCallback(() => { - void runners - .runPush({ serverId, cwd }) - .then(() => { - toastActionSuccess("Pushed"); - return; - }) - .catch((err) => { - toastActionError(err, "Failed to push"); - }); - }, [cwd, runners, serverId, toastActionError, toastActionSuccess]); - - const handlePullAndPush = useCallback(() => { - void runners - .runPullAndPush({ serverId, cwd }) - .then(() => { - toastActionSuccess("Pulled and pushed"); - return; - }) - .catch((err) => { - toastActionError(err, "Failed to pull and push"); - }); - }, [cwd, runners, serverId, toastActionError, toastActionSuccess]); - - const handleCreatePr = useCallback(() => { - void persistShipDefault("pr"); - void runners - .runCreatePr({ serverId, cwd }) - .then(() => { - toastActionSuccess("PR created"); - return; - }) - .catch((err) => { - toastActionError(err, "Failed to create PR"); - }); - }, [cwd, persistShipDefault, runners, serverId, toastActionError, toastActionSuccess]); - - const handleMergeBranch = useCallback(() => { - if (!baseRef) { - toastError("Base ref unavailable"); - return; - } - void persistShipDefault("merge"); - void runners - .runMergeBranch({ serverId, cwd, baseRef }) - .then(() => { - onMergeBranchSuccess(); - toastActionSuccess("Merged"); - return; - }) - .catch((err) => { - toastActionError(err, "Failed to merge"); - }); - }, [ - baseRef, - cwd, - onMergeBranchSuccess, - persistShipDefault, - runners, - serverId, - toastActionError, - toastActionSuccess, - toastError, - ]); - - const handleMergeFromBase = useCallback(() => { - if (!baseRef) { - toastError("Base ref unavailable"); - return; - } - void runners - .runMergeFromBase({ serverId, cwd, baseRef }) - .then(() => { - toastActionSuccess("Updated"); - return; - }) - .catch((err) => { - toastActionError(err, "Failed to merge from base"); - }); - }, [baseRef, cwd, runners, serverId, toastActionError, toastActionSuccess, toastError]); - - const handleArchiveWorktree = useCallback(() => { - const worktreePath = status?.cwd; - if (!worktreePath) { - toastError("Worktree path unavailable"); - return; - } - const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); - void runners - .runArchiveWorktree({ serverId, cwd, worktreePath }) - .then(() => { - onArchiveSuccess(targetWorkingDir); - return; - }) - .catch((err) => { - toastActionError(err, "Failed to archive worktree"); - }); - }, [cwd, onArchiveSuccess, runners, serverId, status, toastActionError, toastError]); - - return { - handleCommit, - handlePull, - handlePush, - handlePullAndPush, - handleCreatePr, - handleMergeBranch, - handleMergeFromBase, - handleArchiveWorktree, - }; -} - interface DeriveStatusStateInputs { status: ReturnType["status"]; isStatusLoading: boolean; @@ -1642,35 +1431,6 @@ function deriveStatusState({ }; } -interface DerivedBranchState { - aheadCount: number; - behindBaseCount: number; - aheadOfOrigin: number; - behindOfOrigin: number; - hasPullRequest: boolean; - hasRemote: boolean; - isPaseoOwnedWorktree: boolean; - isMergedPullRequest: boolean; - currentBranch: string | null | undefined; -} - -function deriveBranchState( - gitStatus: DerivedStatusState["gitStatus"], - prStatus: { url?: string | null; isMerged?: boolean | null } | null | undefined, -): DerivedBranchState { - return { - aheadCount: gitStatus?.aheadBehind?.ahead ?? 0, - behindBaseCount: gitStatus?.aheadBehind?.behind ?? 0, - aheadOfOrigin: gitStatus?.aheadOfOrigin ?? 0, - behindOfOrigin: gitStatus?.behindOfOrigin ?? 0, - hasPullRequest: Boolean(prStatus?.url), - hasRemote: gitStatus?.hasRemote ?? false, - isPaseoOwnedWorktree: gitStatus?.isPaseoOwnedWorktree ?? false, - isMergedPullRequest: Boolean(prStatus?.isMerged), - currentBranch: gitStatus?.currentBranch, - }; -} - function computeBaseRefLabel(baseRef: string | undefined): string { if (!baseRef) return "base"; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); @@ -1687,36 +1447,6 @@ function computeCommittedDiffDescription( return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`; } -function computeShouldPromoteArchive( - isPaseoOwnedWorktree: boolean, - hasUncommittedChanges: boolean, - postShipArchiveSuggested: boolean, - isMergedPullRequest: boolean, -): boolean { - return ( - isPaseoOwnedWorktree && - !hasUncommittedChanges && - (postShipArchiveSuggested || isMergedPullRequest) - ); -} - -function computeDisabledStates( - actionsDisabled: boolean, - statuses: GitActionsStatusInputs, -): GitActionsDisabledInputs { - const pending = "pending"; - return { - commitDisabled: actionsDisabled || statuses.commitStatus === pending, - pullDisabled: actionsDisabled || statuses.pullStatus === pending, - pushDisabled: actionsDisabled || statuses.pushStatus === pending, - pullAndPushDisabled: actionsDisabled || statuses.pullAndPushStatus === pending, - prDisabled: actionsDisabled || statuses.prCreateStatus === pending, - mergeDisabled: actionsDisabled || statuses.mergeStatus === pending, - mergeFromBaseDisabled: actionsDisabled || statuses.mergeFromBaseStatus === pending, - archiveDisabled: actionsDisabled || statuses.archiveStatus === pending, - }; -} - function computePrErrorMessage( githubFeaturesEnabled: boolean, prPayloadError: { message?: string } | null | undefined, @@ -1736,128 +1466,6 @@ function buildToggleButtonStyle( ]; } -function createPrHandler( - prStatus: { url?: string | null } | null | undefined, - handleCreatePr: () => void, -): () => void { - return () => { - if (prStatus?.url) { - openURLInNewTab(prStatus.url); - return; - } - handleCreatePr(); - }; -} - -interface GitActionsStatusInputs { - commitStatus: CheckoutGitActionStatus; - pullStatus: CheckoutGitActionStatus; - pushStatus: CheckoutGitActionStatus; - pullAndPushStatus: CheckoutGitActionStatus; - prCreateStatus: CheckoutGitActionStatus; - mergeStatus: CheckoutGitActionStatus; - mergeFromBaseStatus: CheckoutGitActionStatus; - archiveStatus: CheckoutGitActionStatus; -} - -interface GitActionsDisabledInputs { - commitDisabled: boolean; - pullDisabled: boolean; - pushDisabled: boolean; - pullAndPushDisabled: boolean; - prDisabled: boolean; - mergeDisabled: boolean; - mergeFromBaseDisabled: boolean; - archiveDisabled: boolean; -} - -interface BuildGitActionsParams { - policy: { - isGit: boolean; - githubFeaturesEnabled: boolean; - hasPullRequest: boolean; - pullRequestUrl: string | null; - hasRemote: boolean; - isPaseoOwnedWorktree: boolean; - isOnBaseBranch: boolean; - hasUncommittedChanges: boolean; - baseRefAvailable: boolean; - baseRefLabel: string; - aheadCount: number; - behindBaseCount: number; - aheadOfOrigin: number; - behindOfOrigin: number; - shouldPromoteArchive: boolean; - shipDefault: "merge" | "pr"; - }; - statuses: GitActionsStatusInputs; - disabled: GitActionsDisabledInputs; - handlers: GitActionHandlers & { handlePr: () => void }; - iconColor: string; -} - -function buildGitActionsForPane({ - policy, - statuses, - disabled, - handlers, - iconColor, -}: BuildGitActionsParams): GitActions { - return buildGitActions({ - ...policy, - runtime: { - commit: { - disabled: disabled.commitDisabled, - status: statuses.commitStatus, - icon: , - handler: handlers.handleCommit, - }, - pull: { - disabled: disabled.pullDisabled, - status: statuses.pullStatus, - icon: , - handler: handlers.handlePull, - }, - push: { - disabled: disabled.pushDisabled, - status: statuses.pushStatus, - icon: , - handler: handlers.handlePush, - }, - "pull-and-push": { - disabled: disabled.pullAndPushDisabled, - status: statuses.pullAndPushStatus, - icon: , - handler: handlers.handlePullAndPush, - }, - pr: { - disabled: disabled.prDisabled, - status: policy.hasPullRequest ? "idle" : statuses.prCreateStatus, - icon: , - handler: handlers.handlePr, - }, - "merge-branch": { - disabled: disabled.mergeDisabled, - status: statuses.mergeStatus, - icon: , - handler: handlers.handleMergeBranch, - }, - "merge-from-base": { - disabled: disabled.mergeFromBaseDisabled, - status: statuses.mergeFromBaseStatus, - icon: , - handler: handlers.handleMergeFromBase, - }, - "archive-worktree": { - disabled: disabled.archiveDisabled, - status: statuses.archiveStatus, - icon: , - handler: handlers.handleArchiveWorktree, - }, - }, - }); -} - function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean { return input.paneEnabled && input.isGit; } @@ -1870,13 +1478,10 @@ export function GitDiffPane({ enabled, }: GitDiffPaneProps) { const { theme } = useUnistyles(); - const toast = useToast(); const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile; const [diffModeOverride, setDiffModeOverride] = useState(null); - const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); - const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = useChangesPreferences(); const wrapLines = changesPreferences.wrapLines; @@ -1943,15 +1548,7 @@ export function GitDiffPane({ error: statusError, } = useCheckoutStatusQuery({ serverId, cwd }); const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError }); - const { - gitStatus, - isGit, - notGit, - statusErrorMessage, - baseRef, - hasUncommittedChanges, - actionsDisabled, - } = statusState; + const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState; // Auto-select diff mode based on state: uncommitted when dirty, base when clean const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base"; @@ -2041,11 +1638,7 @@ export function GitDiffPane({ setWorkspaceAttachments, workspaceAttachmentScopeKey, ]); - const { - status: prStatus, - githubFeaturesEnabled, - payloadError: prPayloadError, - } = useCheckoutPrStatusQuery({ + const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({ serverId, cwd, enabled: isGit, @@ -2079,45 +1672,6 @@ export function GitDiffPane({ const diffBodyLineHeight = theme.lineHeight.diff; const diffBodyChromeHeight = theme.borderWidth[1] * 2; const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight; - const shipDefaultStorageKey = useMemo(() => { - if (!gitStatus?.repoRoot) { - return null; - } - return `@paseo:changes-ship-default:${gitStatus.repoRoot}`; - }, [gitStatus?.repoRoot]); - - useEffect(() => { - if (!shipDefaultStorageKey) { - return; - } - let isActive = true; - AsyncStorage.getItem(shipDefaultStorageKey) - .then((value) => { - if (!isActive) return; - if (value === "pr" || value === "merge") { - setShipDefault(value); - } - return; - }) - .catch(() => undefined); - return () => { - isActive = false; - }; - }, [shipDefaultStorageKey]); - - const persistShipDefault = useCallback( - async (next: "merge" | "pr") => { - setShipDefault(next); - if (!shipDefaultStorageKey) return; - try { - await AsyncStorage.setItem(shipDefaultStorageKey, next); - } catch { - // Ignore persistence failures; default will reset to "merge". - } - }, - [shipDefaultStorageKey], - ); - const { flatItems, stickyHeaderIndices } = useMemo(() => { const items: DiffFlatItem[] = []; const stickyIndices: number[] = []; @@ -2304,119 +1858,6 @@ export function GitDiffPane({ setDiffModeOverride(null); }, [autoDiffMode]); - const commitStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "commit" }), - ); - const pullStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "pull" }), - ); - const pushStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "push" }), - ); - const pullAndPushStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "pull-and-push" }), - ); - const prCreateStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "create-pr" }), - ); - const mergeStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "merge-branch" }), - ); - const mergeFromBaseStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "merge-from-base" }), - ); - const archiveStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "archive-worktree" }), - ); - - const runCommit = useCheckoutGitActionsStore((state) => state.commit); - const runPull = useCheckoutGitActionsStore((state) => state.pull); - const runPush = useCheckoutGitActionsStore((state) => state.push); - const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush); - const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); - const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); - const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); - const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); - - const toastActionError = useCallback( - (error: unknown, fallback: string) => { - const message = error instanceof Error ? error.message : fallback; - toast.error(message); - }, - [toast], - ); - - const toastActionSuccess = useCallback( - (message: string) => { - toast.show(message, { variant: "success" }); - }, - [toast], - ); - - const handleMergeBranchSuccess = useCallback(() => { - setPostShipArchiveSuggested(true); - }, []); - - const handleArchiveSuccess = useCallback( - (targetWorkingDir: string) => { - navigateToWorkspace(serverId, targetWorkingDir); - }, - [serverId], - ); - - const toastError = useCallback( - (message: string) => { - toast.error(message); - }, - [toast], - ); - - const runners = useMemo( - () => ({ - runCommit, - runPull, - runPush, - runPullAndPush, - runCreatePr, - runMergeBranch, - runMergeFromBase, - runArchiveWorktree, - }), - [ - runArchiveWorktree, - runCommit, - runCreatePr, - runMergeBranch, - runMergeFromBase, - runPull, - runPullAndPush, - runPush, - ], - ); - - const { - handleCommit, - handlePull, - handlePush, - handlePullAndPush, - handleCreatePr, - handleMergeBranch, - handleMergeFromBase, - handleArchiveWorktree, - } = useGitActionHandlers({ - serverId, - cwd, - baseRef, - status, - runners, - persistShipDefault, - toastError, - toastActionError, - toastActionSuccess, - onMergeBranchSuccess: handleMergeBranchSuccess, - onArchiveSuccess: handleArchiveSuccess, - }); - const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { if (item.type === "header") { @@ -2499,59 +1940,30 @@ export function GitDiffPane({ const hasChanges = files.length > 0; const diffErrorMessage = diffPayloadError?.message ?? null; const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError); - const branchLabel = computeBranchLabel(gitStatus?.currentBranch, notGit); - const branchState = useMemo(() => deriveBranchState(gitStatus, prStatus), [gitStatus, prStatus]); - const { - aheadCount, - behindBaseCount, - aheadOfOrigin, - behindOfOrigin, - hasPullRequest, - hasRemote, - isPaseoOwnedWorktree, - isMergedPullRequest, - currentBranch, - } = branchState; const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]); + const iconColor = theme.colors.foregroundMuted; + const gitActionsIcons = useMemo( + () => ({ + commit: , + pull: , + push: , + pullAndPush: , + viewPr: , + createPr: , + mergePrSquash: , + mergePrMerge: , + mergePrRebase: , + merge: , + mergeFromBase: , + archive: , + }), + [iconColor], + ); + const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons }); const committedDiffDescription = useMemo( () => computeCommittedDiffDescription(branchLabel, baseRefLabel), [baseRefLabel, branchLabel], ); - const isOnBaseBranch = currentBranch === baseRefLabel; - const shouldPromoteArchive = computeShouldPromoteArchive( - isPaseoOwnedWorktree, - hasUncommittedChanges, - postShipArchiveSuggested, - isMergedPullRequest, - ); - - const gitActionsStatuses = useMemo( - () => ({ - commitStatus, - pullStatus, - pushStatus, - pullAndPushStatus, - prCreateStatus, - mergeStatus, - mergeFromBaseStatus, - archiveStatus, - }), - [ - archiveStatus, - commitStatus, - mergeFromBaseStatus, - mergeStatus, - prCreateStatus, - pullAndPushStatus, - pullStatus, - pushStatus, - ], - ); - - const gitActionsDisabled = useMemo( - () => computeDisabledStates(actionsDisabled, gitActionsStatuses), - [actionsDisabled, gitActionsStatuses], - ); const emptyMessage = computeEmptyMessage( changesPreferences.hideWhitespace, @@ -2583,101 +1995,6 @@ export function GitDiffPane({ /> ); - useEffect(() => { - setPostShipArchiveSuggested(false); - }, [cwd]); - - // ========================================================================== - // Git Actions (Data-Oriented) - // ========================================================================== - // All possible actions are computed as data, then partitioned into: - // - primary: The main CTA button - // - secondary: Dropdown next to primary button - // - menu: Kebab overflow menu - // ========================================================================== - - const handlePr = useMemo( - () => createPrHandler(prStatus, handleCreatePr), - [handleCreatePr, prStatus], - ); - - const gitActionsHandlers = useMemo( - () => ({ - handleCommit, - handlePull, - handlePush, - handlePullAndPush, - handleCreatePr, - handleMergeBranch, - handleMergeFromBase, - handleArchiveWorktree, - handlePr, - }), - [ - handleArchiveWorktree, - handleCommit, - handleCreatePr, - handleMergeBranch, - handleMergeFromBase, - handlePr, - handlePull, - handlePullAndPush, - handlePush, - ], - ); - - const gitActions: GitActions = useMemo( - () => - buildGitActionsForPane({ - policy: { - isGit, - githubFeaturesEnabled, - hasPullRequest, - pullRequestUrl: prStatus?.url ?? null, - hasRemote, - isPaseoOwnedWorktree, - isOnBaseBranch, - hasUncommittedChanges, - baseRefAvailable: Boolean(baseRef), - baseRefLabel, - aheadCount, - behindBaseCount, - aheadOfOrigin, - behindOfOrigin, - shouldPromoteArchive, - shipDefault, - }, - statuses: gitActionsStatuses, - disabled: gitActionsDisabled, - handlers: gitActionsHandlers, - iconColor: theme.colors.foregroundMuted, - }), - [ - aheadCount, - aheadOfOrigin, - baseRef, - baseRefLabel, - behindBaseCount, - behindOfOrigin, - gitActionsDisabled, - gitActionsHandlers, - gitActionsStatuses, - githubFeaturesEnabled, - hasPullRequest, - hasRemote, - hasUncommittedChanges, - isGit, - isOnBaseBranch, - isPaseoOwnedWorktree, - prStatus?.url, - shipDefault, - shouldPromoteArchive, - theme.colors.foregroundMuted, - ], - ); - - // Helper to get display label based on status - return ( {!hideHeaderRow ? ( diff --git a/packages/app/src/utils/git-diff-scroll.test.ts b/packages/app/src/git/diff-scroll.test.ts similarity index 95% rename from packages/app/src/utils/git-diff-scroll.test.ts rename to packages/app/src/git/diff-scroll.test.ts index f55d77fa9..b5dccfe98 100644 --- a/packages/app/src/utils/git-diff-scroll.test.ts +++ b/packages/app/src/git/diff-scroll.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { shouldAnchorHeaderBeforeCollapse } from "./git-diff-scroll"; +import { shouldAnchorHeaderBeforeCollapse } from "./diff-scroll"; describe("shouldAnchorHeaderBeforeCollapse", () => { it("skips anchor when header is fully visible in viewport", () => { diff --git a/packages/app/src/utils/git-diff-scroll.ts b/packages/app/src/git/diff-scroll.ts similarity index 100% rename from packages/app/src/utils/git-diff-scroll.ts rename to packages/app/src/git/diff-scroll.ts diff --git a/packages/app/src/utils/github-repo-url.test.ts b/packages/app/src/git/github-url.test.ts similarity index 98% rename from packages/app/src/utils/github-repo-url.test.ts rename to packages/app/src/git/github-url.test.ts index cae70083d..43b4771c6 100644 --- a/packages/app/src/utils/github-repo-url.test.ts +++ b/packages/app/src/git/github-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-repo-url"; +import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url"; describe("parseGitHubRepoFromRemote", () => { it.each([ diff --git a/packages/app/src/utils/github-repo-url.ts b/packages/app/src/git/github-url.ts similarity index 100% rename from packages/app/src/utils/github-repo-url.ts rename to packages/app/src/git/github-url.ts diff --git a/packages/app/src/components/git-actions-policy.test.ts b/packages/app/src/git/policy.test.ts similarity index 51% rename from packages/app/src/components/git-actions-policy.test.ts rename to packages/app/src/git/policy.test.ts index 41b76c1b2..13e4e1744 100644 --- a/packages/app/src/components/git-actions-policy.test.ts +++ b/packages/app/src/git/policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy"; +import { buildGitActions, type BuildGitActionsInput } from "./policy"; function createInput(overrides: Partial = {}): BuildGitActionsInput { return { @@ -8,6 +8,10 @@ function createInput(overrides: Partial = {}): BuildGitAct githubFeaturesEnabled: true, hasPullRequest: false, pullRequestUrl: null, + pullRequestState: null, + pullRequestIsDraft: false, + pullRequestIsMerged: false, + pullRequestMergeable: "UNKNOWN", hasRemote: false, isPaseoOwnedWorktree: false, isOnBaseBranch: true, @@ -46,6 +50,21 @@ function createInput(overrides: Partial = {}): BuildGitAct status: "idle", handler: () => undefined, }, + "merge-pr-squash": { + disabled: false, + status: "idle", + handler: () => undefined, + }, + "merge-pr-merge": { + disabled: false, + status: "idle", + handler: () => undefined, + }, + "merge-pr-rebase": { + disabled: false, + status: "idle", + handler: () => undefined, + }, "merge-branch": { disabled: false, status: "idle", @@ -158,6 +177,9 @@ describe("git-actions-policy", () => { "merge-from-base", "merge-branch", "pr", + "merge-pr-squash", + "merge-pr-merge", + "merge-pr-rebase", ]); expect( actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"), @@ -213,4 +235,183 @@ describe("git-actions-policy", () => { expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false); expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true); }); + + it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => { + const actions = buildGitActions( + createInput({ + hasRemote: true, + isOnBaseBranch: false, + aheadCount: 2, + hasPullRequest: true, + pullRequestUrl: "https://example.com/pr/456", + pullRequestState: "open", + pullRequestMergeable: "MERGEABLE", + shipDefault: "pr", + }), + ); + + expect(actions.primary).toMatchObject({ + id: "merge-pr-squash", + label: "Squash and merge", + }); + }); + + it("respects the ship preference when choosing between PR merge and local merge", () => { + const prPreferredActions = buildGitActions( + createInput({ + hasRemote: true, + isOnBaseBranch: false, + aheadCount: 2, + hasPullRequest: true, + pullRequestUrl: "https://example.com/pr/456", + pullRequestState: "open", + pullRequestMergeable: "MERGEABLE", + shipDefault: "pr", + }), + ); + const localMergePreferredActions = buildGitActions( + createInput({ + hasRemote: true, + isOnBaseBranch: false, + aheadCount: 2, + hasPullRequest: true, + pullRequestUrl: "https://example.com/pr/456", + pullRequestState: "open", + pullRequestMergeable: "MERGEABLE", + shipDefault: "merge", + }), + ); + + expect(prPreferredActions.primary).toMatchObject({ + id: "merge-pr-squash", + label: "Squash and merge", + }); + expect(localMergePreferredActions.primary).toMatchObject({ + id: "merge-branch", + label: "Merge locally", + }); + expect( + localMergePreferredActions.secondary.some((action) => action.id === "merge-pr-squash"), + ).toBe(true); + }); + + it("keeps the merge-pr actions in the feature branch menu", () => { + const actions = buildGitActions( + createInput({ + hasRemote: true, + isOnBaseBranch: false, + aheadCount: 2, + hasPullRequest: true, + pullRequestUrl: "https://example.com/pr/456", + pullRequestState: "open", + pullRequestMergeable: "MERGEABLE", + }), + ); + + expect(actions.secondary.map((action) => action.id)).toEqual([ + "pull", + "push", + "pull-and-push", + "merge-from-base", + "merge-branch", + "pr", + "merge-pr-squash", + "merge-pr-merge", + "merge-pr-rebase", + ]); + }); + + it("uses Merge locally for the local merge action", () => { + const actions = buildGitActions( + createInput({ + isOnBaseBranch: false, + aheadCount: 2, + }), + ); + const action = actions.secondary.find((entry) => entry.id === "merge-branch"); + + expect(action).toMatchObject({ label: "Merge locally" }); + }); + + it.each([ + [ + "draft", + { pullRequestIsDraft: true }, + "Merge PR isn't available because the pull request is still a draft", + ], + [ + "merged", + { pullRequestIsMerged: true }, + "Merge PR isn't available because the pull request is already merged", + ], + [ + "closed", + { pullRequestState: "closed" as const }, + "Merge PR isn't available because the pull request is closed", + ], + [ + "conflicting", + { pullRequestMergeable: "CONFLICTING" as const }, + "Merge PR isn't available because the pull request has conflicts", + ], + ])("marks merge-pr actions unavailable when the PR is %s", (_name, overrides, message) => { + const actions = buildGitActions( + createInput({ + hasRemote: true, + isOnBaseBranch: false, + aheadCount: 2, + hasPullRequest: true, + pullRequestUrl: "https://example.com/pr/456", + pullRequestState: "open", + pullRequestMergeable: "MERGEABLE", + ...overrides, + }), + ); + const mergePrActions = actions.secondary.filter((action) => + ["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id), + ); + + expect(mergePrActions).toHaveLength(3); + expect(mergePrActions.map((action) => action.unavailableMessage)).toEqual([ + message, + message, + message, + ]); + expect(mergePrActions.map((action) => action.disabled)).toEqual([true, true, true]); + }); + + it("groups merge-pr actions behind their own menu separator via startsGroup", () => { + const actions = buildGitActions( + createInput({ + hasRemote: true, + isOnBaseBranch: false, + aheadCount: 2, + hasPullRequest: true, + pullRequestUrl: "https://example.com/pr/456", + pullRequestState: "open", + pullRequestMergeable: "MERGEABLE", + isPaseoOwnedWorktree: true, + }), + ); + + const allActions = [...actions.secondary]; + + const groupStarters = allActions + .filter((action) => action.startsGroup) + .map((action) => action.id); + const nonGroupStarters = allActions + .filter((action) => !action.startsGroup) + .map((action) => action.id); + + expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]); + expect(nonGroupStarters).toEqual([ + "pull", + "push", + "pull-and-push", + "merge-branch", + "pr", + "merge-pr-merge", + "merge-pr-rebase", + ]); + }); }); diff --git a/packages/app/src/components/git-actions-policy.ts b/packages/app/src/git/policy.ts similarity index 71% rename from packages/app/src/components/git-actions-policy.ts rename to packages/app/src/git/policy.ts index e563806ba..40eee5379 100644 --- a/packages/app/src/components/git-actions-policy.ts +++ b/packages/app/src/git/policy.ts @@ -1,6 +1,7 @@ import type { ReactElement } from "react"; import type { ActionStatus } from "@/components/ui/dropdown-menu"; +import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@server/shared/messages"; export type GitActionId = | "commit" @@ -8,6 +9,9 @@ export type GitActionId = | "push" | "pull-and-push" | "pr" + | "merge-pr-squash" + | "merge-pr-merge" + | "merge-pr-rebase" | "merge-branch" | "merge-from-base" | "archive-worktree"; @@ -21,6 +25,8 @@ export interface GitAction { status: ActionStatus; unavailableMessage?: string; icon?: ReactElement; + /** When true, a menu separator should be rendered before this item. */ + startsGroup: boolean; handler: () => void; } @@ -42,6 +48,10 @@ export interface BuildGitActionsInput { githubFeaturesEnabled: boolean; hasPullRequest: boolean; pullRequestUrl: string | null; + pullRequestState: "open" | "closed" | null; + pullRequestIsDraft: boolean; + pullRequestIsMerged: boolean; + pullRequestMergeable: PullRequestMergeable; hasRemote: boolean; isPaseoOwnedWorktree: boolean; isOnBaseBranch: boolean; @@ -58,7 +68,29 @@ export interface BuildGitActionsInput { } const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"]; -const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"]; +const FEATURE_ACTION_IDS: GitActionId[] = [ + "merge-from-base", + "merge-branch", + "pr", + "merge-pr-squash", + "merge-pr-merge", + "merge-pr-rebase", +]; + +const MERGE_PR_METHODS: Record< + "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase", + { label: string; method: CheckoutPrMergeMethod } +> = { + "merge-pr-squash": { label: "Squash and merge", method: "squash" }, + "merge-pr-merge": { label: "Create a merge commit", method: "merge" }, + "merge-pr-rebase": { label: "Rebase and merge", method: "rebase" }, +}; + +export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null { + if (state === "open") return "open"; + if (state === "closed") return "closed"; + return null; +} export function buildGitActions(input: BuildGitActionsInput): GitActions { if (!input.isGit) { @@ -75,6 +107,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { disabled: input.runtime.commit.disabled, status: input.runtime.commit.status, icon: input.runtime.commit.icon, + startsGroup: false, handler: input.runtime.commit.handler, }); @@ -87,6 +120,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { status: input.runtime.pull.status, unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input), icon: input.runtime.pull.icon, + startsGroup: false, handler: input.runtime.pull.handler, }); @@ -99,6 +133,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { status: input.runtime.push.status, unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input), icon: input.runtime.push.icon, + startsGroup: false, handler: input.runtime.push.handler, }); @@ -113,14 +148,19 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { ? undefined : getPullAndPushUnavailableMessage(input), icon: input.runtime["pull-and-push"].icon, + startsGroup: false, handler: input.runtime["pull-and-push"].handler, }); allActions.set("pr", buildPrAction(input)); + allActions.set("merge-pr-squash", buildMergePrAction(input, "merge-pr-squash")); + allActions.set("merge-pr-merge", buildMergePrAction(input, "merge-pr-merge")); + allActions.set("merge-pr-rebase", buildMergePrAction(input, "merge-pr-rebase")); + allActions.set("merge-branch", { id: "merge-branch", - label: `Merge into ${input.baseRefLabel}`, + label: "Merge locally", pendingLabel: "Merging...", successLabel: "Merged", disabled: input.runtime["merge-branch"].disabled, @@ -129,6 +169,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { ? undefined : getMergeBranchUnavailableMessage(input), icon: input.runtime["merge-branch"].icon, + startsGroup: false, handler: input.runtime["merge-branch"].handler, }); @@ -143,6 +184,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { ? undefined : getMergeFromBaseUnavailableMessage(input), icon: input.runtime["merge-from-base"].icon, + startsGroup: true, handler: input.runtime["merge-from-base"].handler, }); @@ -158,6 +200,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { ? undefined : "Archive isn't available here because this workspace was not created as a Paseo worktree", icon: input.runtime["archive-worktree"].icon, + startsGroup: true, handler: input.runtime["archive-worktree"].handler, }); @@ -195,11 +238,17 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null { if (!input.isOnBaseBranch && canMergeFromBase(input)) { return "merge-from-base"; } + if (canMergePr(input) && input.shipDefault === "pr") { + return "merge-pr-squash"; + } + if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") { + return "merge-branch"; + } if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) { return "pr"; } if (!input.isOnBaseBranch && input.aheadCount > 0) { - return input.shipDefault === "merge" ? "merge-branch" : "pr"; + return "pr"; } return null; } @@ -218,6 +267,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { ? undefined : "View PR isn't available right now because GitHub isn't connected", icon: input.runtime.pr.icon, + startsGroup: false, handler: input.runtime.pr.handler, }; } @@ -233,10 +283,32 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { ? undefined : getCreatePrUnavailableMessage(input), icon: input.runtime.pr.icon, + startsGroup: false, handler: input.runtime.pr.handler, }; } +function buildMergePrAction( + input: BuildGitActionsInput, + id: "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase", +): GitAction { + const runtime = input.runtime[id]; + const config = MERGE_PR_METHODS[id]; + const unavailableMessage = getMergePrUnavailableMessage(input); + return { + id, + label: config.label, + pendingLabel: "Merging PR...", + successLabel: "PR merged", + disabled: runtime.disabled || shouldDisableMergePrAction(input), + status: runtime.status, + unavailableMessage: runtime.disabled ? undefined : unavailableMessage, + icon: runtime.icon, + startsGroup: id === "merge-pr-squash", + handler: runtime.handler, + }; +} + function canPull(input: BuildGitActionsInput): boolean { return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0; } @@ -254,6 +326,22 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean { ); } +function canMergePr(input: BuildGitActionsInput): boolean { + return ( + input.githubFeaturesEnabled && + input.hasPullRequest && + input.pullRequestState === "open" && + !input.pullRequestIsDraft && + !input.pullRequestIsMerged && + input.pullRequestMergeable === "MERGEABLE" && + input.aheadCount > 0 && + !input.hasUncommittedChanges && + input.behindOfOrigin === 0 && + input.aheadOfOrigin === 0 && + !canMergeFromBase(input) + ); +} + function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.hasRemote) { return "Pull isn't available here because this branch is not connected to a remote yet"; @@ -328,3 +416,34 @@ function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string } return undefined; } + +function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | undefined { + if (!input.githubFeaturesEnabled) { + return "Merge PR isn't available right now because GitHub isn't connected"; + } + if (!input.hasPullRequest) { + return "Merge PR isn't available because there isn't a pull request yet"; + } + if (input.pullRequestIsDraft) { + return "Merge PR isn't available because the pull request is still a draft"; + } + if (input.pullRequestIsMerged) { + return "Merge PR isn't available because the pull request is already merged"; + } + if (input.pullRequestState === "closed") { + return "Merge PR isn't available because the pull request is closed"; + } + if (input.pullRequestMergeable === "CONFLICTING") { + return "Merge PR isn't available because the pull request has conflicts"; + } + return undefined; +} + +function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean { + return ( + input.pullRequestIsDraft || + input.pullRequestIsMerged || + input.pullRequestState === "closed" || + input.pullRequestMergeable === "CONFLICTING" + ); +} diff --git a/packages/app/src/utils/pr-pane-data.test.ts b/packages/app/src/git/pr-pane-data.test.ts similarity index 99% rename from packages/app/src/utils/pr-pane-data.test.ts rename to packages/app/src/git/pr-pane-data.test.ts index fa98b5b4d..6d762886b 100644 --- a/packages/app/src/utils/pr-pane-data.test.ts +++ b/packages/app/src/git/pr-pane-data.test.ts @@ -23,6 +23,7 @@ const baseStatus: CheckoutPrStatus = { headRefName: "feature/pr-pane", isMerged: false, isDraft: false, + mergeable: "UNKNOWN", checks: [], reviewDecision: null, }; diff --git a/packages/app/src/utils/pr-pane-data.ts b/packages/app/src/git/pr-pane-data.ts similarity index 100% rename from packages/app/src/utils/pr-pane-data.ts rename to packages/app/src/git/pr-pane-data.ts diff --git a/packages/app/src/components/pr-pane.tsx b/packages/app/src/git/pr-pane.tsx similarity index 99% rename from packages/app/src/components/pr-pane.tsx rename to packages/app/src/git/pr-pane.tsx index 98269490f..a04ff952d 100644 --- a/packages/app/src/components/pr-pane.tsx +++ b/packages/app/src/git/pr-pane.tsx @@ -16,14 +16,14 @@ import { MessageSquare, } from "lucide-react-native"; import { openExternalUrl } from "@/utils/open-external-url"; -import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data"; +import { getActivityVerb, getStateLabel } from "@/git/pr-pane-data"; import type { CheckStatus, PrPaneActivity, PrPaneCheck, PrPaneData, PrState, -} from "@/utils/pr-pane-data"; +} from "@/git/pr-pane-data"; function rowPressableStyle({ hovered }: { hovered?: boolean }) { return [styles.row, Boolean(hovered) && styles.hoverable]; diff --git a/packages/app/src/utils/pr-tab-label.test.ts b/packages/app/src/git/pr-tab-label.test.ts similarity index 100% rename from packages/app/src/utils/pr-tab-label.test.ts rename to packages/app/src/git/pr-tab-label.test.ts diff --git a/packages/app/src/utils/pr-tab-label.ts b/packages/app/src/git/pr-tab-label.ts similarity index 100% rename from packages/app/src/utils/pr-tab-label.ts rename to packages/app/src/git/pr-tab-label.ts diff --git a/packages/app/src/hooks/use-git-actions.ts b/packages/app/src/git/use-actions.ts similarity index 72% rename from packages/app/src/hooks/use-git-actions.ts rename to packages/app/src/git/use-actions.ts index 58ada44f7..a94713c0c 100644 --- a/packages/app/src/hooks/use-git-actions.ts +++ b/packages/app/src/git/use-actions.ts @@ -1,24 +1,16 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { - type CheckoutGitActionStatus, - useCheckoutGitActionsStore, -} from "@/stores/checkout-git-actions-store"; -import { - type CheckoutStatusPayload, - useCheckoutStatusQuery, -} from "@/hooks/use-checkout-status-query"; -import { - type CheckoutPrStatusPayload, - useCheckoutPrStatusQuery, -} from "@/hooks/use-checkout-pr-status-query"; -import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; +import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store"; +import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query"; +import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; +import { buildGitActions, narrowPullRequestState, type GitActions } from "@/git/policy"; +import type { CheckoutPrMergeMethod } from "@server/shared/messages"; import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; import { useToast } from "@/contexts/toast-context"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; -export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; +export type { GitActionId, GitAction, GitActions } from "@/git/policy"; function openURLInNewTab(url: string): void { void openExternalUrl(url); @@ -47,55 +39,6 @@ function formatBaseRefLabel(baseRef: string | undefined): string { return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; } -function useGitActionStatuses( - serverId: string, - cwd: string, -): { - commitStatus: CheckoutGitActionStatus; - pullStatus: CheckoutGitActionStatus; - pushStatus: CheckoutGitActionStatus; - pullAndPushStatus: CheckoutGitActionStatus; - prCreateStatus: CheckoutGitActionStatus; - mergeStatus: CheckoutGitActionStatus; - mergeFromBaseStatus: CheckoutGitActionStatus; - archiveStatus: CheckoutGitActionStatus; -} { - const commitStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "commit" }), - ); - const pullStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "pull" }), - ); - const pushStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "push" }), - ); - const pullAndPushStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "pull-and-push" }), - ); - const prCreateStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "create-pr" }), - ); - const mergeStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "merge-branch" }), - ); - const mergeFromBaseStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "merge-from-base" }), - ); - const archiveStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "archive-worktree" }), - ); - return { - commitStatus, - pullStatus, - pushStatus, - pullAndPushStatus, - prCreateStatus, - mergeStatus, - mergeFromBaseStatus, - archiveStatus, - }; -} - type PrStatusValue = NonNullable | null; interface DeriveGitActionsStateArgs { @@ -181,27 +124,6 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio }; } -function useGitActionRunners() { - const runCommit = useCheckoutGitActionsStore((state) => state.commit); - const runPull = useCheckoutGitActionsStore((state) => state.pull); - const runPush = useCheckoutGitActionsStore((state) => state.push); - const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush); - const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); - const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); - const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); - const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); - return { - runCommit, - runPull, - runPush, - runPullAndPush, - runCreatePr, - runMergeBranch, - runMergeFromBase, - runArchiveWorktree, - }; -} - interface UseGitActionsInput { serverId: string; cwd: string; @@ -212,6 +134,9 @@ interface UseGitActionsInput { pullAndPush: ReactElement; viewPr: ReactElement; createPr: ReactElement; + mergePrSquash: ReactElement; + mergePrMerge: ReactElement; + mergePrRebase: ReactElement; merge: ReactElement; mergeFromBase: ReactElement; archive: ReactElement; @@ -287,27 +212,51 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use setPostShipArchiveSuggested(false); }, [cwd]); - const { - commitStatus, - pullStatus, - pushStatus, - pullAndPushStatus, - prCreateStatus, - mergeStatus, - mergeFromBaseStatus, - archiveStatus, - } = useGitActionStatuses(serverId, cwd); + const commitStatus = useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "commit" }), + ); + const pullStatus = useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "pull" }), + ); + const pushStatus = useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "push" }), + ); + const pullAndPushStatus = useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "pull-and-push" }), + ); + const prCreateStatus = useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "create-pr" }), + ); + const mergePrStatuses: Record = { + squash: useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "merge-pr-squash" }), + ), + merge: useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "merge-pr-merge" }), + ), + rebase: useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }), + ), + }; + const mergeStatus = useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "merge-branch" }), + ); + const mergeFromBaseStatus = useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "merge-from-base" }), + ); + const archiveStatus = useCheckoutGitActionsStore((s) => + s.getStatus({ serverId, cwd, actionId: "archive-worktree" }), + ); - const { - runCommit, - runPull, - runPush, - runPullAndPush, - runCreatePr, - runMergeBranch, - runMergeFromBase, - runArchiveWorktree, - } = useGitActionRunners(); + const runCommit = useCheckoutGitActionsStore((s) => s.commit); + const runPull = useCheckoutGitActionsStore((s) => s.pull); + const runPush = useCheckoutGitActionsStore((s) => s.push); + const runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush); + const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr); + const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr); + const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch); + const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase); + const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree); const toastActionError = useCallback( (error: unknown, fallback: string) => { @@ -381,6 +330,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }); }, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]); + const handleMergePr = useCallback( + (method: CheckoutPrMergeMethod) => { + void persistShipDefault("pr"); + void runMergePr({ serverId, cwd, method }) + .then(() => { + setPostShipArchiveSuggested(true); + toastActionSuccess("PR merged"); + return; + }) + .catch((err) => { + toastActionError(err, "Failed to merge PR"); + }); + }, + [cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess], + ); + const handleMergeBranch = useCallback(() => { if (!baseRef) { toast.error("Base ref unavailable"); @@ -463,15 +428,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use shouldPromoteArchive, } = derived; - const commitDisabled = isActionDisabled(actionsDisabled, commitStatus); - const pullDisabled = isActionDisabled(actionsDisabled, pullStatus); - const prDisabled = isActionDisabled(actionsDisabled, prCreateStatus); - const mergeDisabled = isActionDisabled(actionsDisabled, mergeStatus); - const mergeFromBaseDisabled = isActionDisabled(actionsDisabled, mergeFromBaseStatus); - const pushDisabled = isActionDisabled(actionsDisabled, pushStatus); - const pullAndPushDisabled = isActionDisabled(actionsDisabled, pullAndPushStatus); - const archiveDisabled = isActionDisabled(actionsDisabled, archiveStatus); - const branchLabel = resolveBranchLabel({ currentBranch: gitStatus?.currentBranch, notGit, @@ -492,6 +448,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use githubFeaturesEnabled, hasPullRequest, pullRequestUrl: prStatus?.url ?? null, + pullRequestState: narrowPullRequestState(prStatus?.state), + pullRequestIsDraft: prStatus?.isDraft ?? false, + pullRequestIsMerged: prStatus?.isMerged ?? false, + pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN", hasRemote, isPaseoOwnedWorktree, isOnBaseBranch, @@ -506,49 +466,67 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use shipDefault, runtime: { commit: { - disabled: commitDisabled, + disabled: isActionDisabled(actionsDisabled, commitStatus), status: commitStatus, icon: icons.commit, handler: handleCommit, }, pull: { - disabled: pullDisabled, + disabled: isActionDisabled(actionsDisabled, pullStatus), status: pullStatus, icon: icons.pull, handler: handlePull, }, push: { - disabled: pushDisabled, + disabled: isActionDisabled(actionsDisabled, pushStatus), status: pushStatus, icon: icons.push, handler: handlePush, }, "pull-and-push": { - disabled: pullAndPushDisabled, + disabled: isActionDisabled(actionsDisabled, pullAndPushStatus), status: pullAndPushStatus, icon: icons.pullAndPush, handler: handlePullAndPush, }, pr: { - disabled: prDisabled, + disabled: isActionDisabled(actionsDisabled, prCreateStatus), status: hasPullRequest ? "idle" : prCreateStatus, icon: hasPullRequest ? icons.viewPr : icons.createPr, handler: handlePrAction, }, + "merge-pr-squash": { + disabled: isActionDisabled(actionsDisabled, mergePrStatuses.squash), + status: mergePrStatuses.squash, + icon: icons.mergePrSquash, + handler: () => handleMergePr("squash"), + }, + "merge-pr-merge": { + disabled: isActionDisabled(actionsDisabled, mergePrStatuses.merge), + status: mergePrStatuses.merge, + icon: icons.mergePrMerge, + handler: () => handleMergePr("merge"), + }, + "merge-pr-rebase": { + disabled: isActionDisabled(actionsDisabled, mergePrStatuses.rebase), + status: mergePrStatuses.rebase, + icon: icons.mergePrRebase, + handler: () => handleMergePr("rebase"), + }, "merge-branch": { - disabled: mergeDisabled, + disabled: isActionDisabled(actionsDisabled, mergeStatus), status: mergeStatus, icon: icons.merge, handler: handleMergeBranch, }, "merge-from-base": { - disabled: mergeFromBaseDisabled, + disabled: isActionDisabled(actionsDisabled, mergeFromBaseStatus), status: mergeFromBaseStatus, icon: icons.mergeFromBase, handler: handleMergeFromBase, }, "archive-worktree": { - disabled: archiveDisabled, + disabled: isActionDisabled(actionsDisabled, archiveStatus), status: archiveStatus, icon: icons.archive, handler: handleArchiveWorktree, @@ -560,6 +538,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use hasRemote, hasPullRequest, prStatus?.url, + prStatus?.state, + prStatus?.isDraft, + prStatus?.isMerged, + prStatus?.mergeable, aheadCount, behindBaseCount, isPaseoOwnedWorktree, @@ -571,19 +553,15 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use shipDefault, baseRefLabel, shouldPromoteArchive, - commitDisabled, - pullDisabled, - pushDisabled, - pullAndPushDisabled, - prDisabled, - mergeDisabled, - mergeFromBaseDisabled, - archiveDisabled, + actionsDisabled, commitStatus, pullStatus, pushStatus, pullAndPushStatus, prCreateStatus, + mergePrStatuses.squash, + mergePrStatuses.merge, + mergePrStatuses.rebase, mergeStatus, mergeFromBaseStatus, archiveStatus, @@ -592,6 +570,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use handlePush, handlePullAndPush, handlePrAction, + handleMergePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/git/use-diff-query.ts similarity index 98% rename from packages/app/src/hooks/use-checkout-diff-query.ts rename to packages/app/src/git/use-diff-query.ts index 0fe3e1874..ed1a3c876 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -2,7 +2,7 @@ import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useId, useMemo } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; -import { orderCheckoutDiffFiles } from "./checkout-diff-order"; +import { orderCheckoutDiffFiles } from "@/git/diff-order"; function checkoutDiffQueryKey( serverId: string, diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/git/use-pr-status-query.ts similarity index 100% rename from packages/app/src/hooks/use-checkout-pr-status-query.ts rename to packages/app/src/git/use-pr-status-query.ts diff --git a/packages/app/src/hooks/use-checkout-status-query.test.ts b/packages/app/src/git/use-status-query.test.ts similarity index 99% rename from packages/app/src/hooks/use-checkout-status-query.test.ts rename to packages/app/src/git/use-status-query.test.ts index 6db41f3e4..02eb2ffa8 100644 --- a/packages/app/src/hooks/use-checkout-status-query.test.ts +++ b/packages/app/src/git/use-status-query.test.ts @@ -9,7 +9,7 @@ import { } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CheckoutStatusResponse } from "@server/shared/messages"; -import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-checkout-status-query"; +import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-status-query"; type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/git/use-status-query.ts similarity index 100% rename from packages/app/src/hooks/use-checkout-status-query.ts rename to packages/app/src/git/use-status-query.ts diff --git a/packages/app/src/screens/workspace/workspace-git-actions.tsx b/packages/app/src/git/workspace-actions.tsx similarity index 84% rename from packages/app/src/screens/workspace/workspace-git-actions.tsx rename to packages/app/src/git/workspace-actions.tsx index 88546041c..f6b62d3cb 100644 --- a/packages/app/src/screens/workspace/workspace-git-actions.tsx +++ b/packages/app/src/git/workspace-actions.tsx @@ -9,8 +9,8 @@ import { Upload, } from "lucide-react-native"; import { GitHubIcon } from "@/components/icons/github-icon"; -import { GitActionsSplitButton } from "@/components/git-actions-split-button"; -import { useGitActions } from "@/hooks/use-git-actions"; +import { GitActionsSplitButton } from "@/git/actions-split-button"; +import { useGitActions } from "@/git/use-actions"; import type { Theme } from "@/styles/theme"; interface WorkspaceGitActionsProps { @@ -39,6 +39,9 @@ const ICONS = { pullAndPush: , viewPr: , createPr: , + mergePrSquash: , + mergePrMerge: , + mergePrRebase: , merge: , mergeFromBase: , archive: , diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts index 1506b7470..f8c361b24 100644 --- a/packages/app/src/hooks/use-branch-switcher.ts +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -3,7 +3,7 @@ import { useQuery, type QueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@server/client/daemon-client"; import type { ComboboxOption } from "@/components/ui/combobox"; import type { ToastApi } from "@/components/toast-host"; -import { invalidateCheckoutGitQueriesForClient } from "@/stores/checkout-git-actions-store"; +import { invalidateCheckoutGitQueriesForClient } from "@/git/actions-store"; import { confirmDialog } from "@/utils/confirm-dialog"; interface UseBranchSwitcherInput { diff --git a/packages/app/src/hooks/use-pr-pane-data.test.ts b/packages/app/src/hooks/use-pr-pane-data.test.ts index 18977644a..0bb894b36 100644 --- a/packages/app/src/hooks/use-pr-pane-data.test.ts +++ b/packages/app/src/hooks/use-pr-pane-data.test.ts @@ -17,7 +17,7 @@ import { usePrPaneData, type UsePrPaneDataResult, } from "./use-pr-pane-data"; -import { useWorkspacePrHint } from "./use-checkout-pr-status-query"; +import { useWorkspacePrHint } from "@/git/use-pr-status-query"; type CheckoutPrStatus = NonNullable; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; @@ -67,6 +67,7 @@ function status(overrides: Partial = {}): CheckoutPrStatus { headRefName: "feature/pr-pane", isMerged: false, isDraft: false, + mergeable: "UNKNOWN", checks: [], reviewDecision: null, repoOwner: "getpaseo", diff --git a/packages/app/src/hooks/use-pr-pane-data.ts b/packages/app/src/hooks/use-pr-pane-data.ts index 3665e4888..54831dd3b 100644 --- a/packages/app/src/hooks/use-pr-pane-data.ts +++ b/packages/app/src/hooks/use-pr-pane-data.ts @@ -4,8 +4,8 @@ import type { CheckoutPrStatusResponse, PullRequestTimelineResponse, } from "@server/shared/messages"; -import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data"; -import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query"; +import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data"; +import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"]; type PullRequestTimeline = PullRequestTimelineResponse["payload"]; diff --git a/packages/app/src/review/store.test.ts b/packages/app/src/review/store.test.ts index 923655b3e..e8dd83079 100644 --- a/packages/app/src/review/store.test.ts +++ b/packages/app/src/review/store.test.ts @@ -1,7 +1,7 @@ import "@/test/window-local-storage"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { describe, expect, it } from "vitest"; -import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query"; +import type { ParsedDiffFile } from "@/git/use-diff-query"; import { buildReviewAttachmentSnapshot, buildReviewDraftKey, diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index cdc7b5faf..329937e4e 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -3,7 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import type { ComposerAttachment } from "@/attachments/types"; -import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query"; +import type { ParsedDiffFile } from "@/git/use-diff-query"; import { generateMessageId } from "@/types/stream"; import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout"; import type { AgentAttachment } from "@server/shared/messages"; diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 24f29dbf8..ec9808058 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -19,11 +19,11 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/contexts/toast-context"; -import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; +import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; -import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url"; +import { buildGitHubBranchTreeUrl } from "@/git/github-url"; import { openExternalUrl } from "@/utils/open-external-url"; import { isAbsolutePath } from "@/utils/path"; import { isWeb } from "@/constants/platform"; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 099461ba0..cfc8723e5 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -54,7 +54,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { SplitContainer } from "@/components/split-container"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; -import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions"; +import { WorkspaceGitActions } from "@/git/workspace-actions"; import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; @@ -90,10 +90,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; -import { - checkoutStatusQueryKey, - type CheckoutStatusPayload, -} from "@/hooks/use-checkout-status-query"; +import { checkoutStatusQueryKey, type CheckoutStatusPayload } from "@/git/use-status-query"; import type { ListTerminalsResponse } from "@server/shared/messages"; import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { confirmDialog } from "@/utils/confirm-dialog"; diff --git a/packages/app/src/utils/diff-layout.test.ts b/packages/app/src/utils/diff-layout.test.ts index 6a2adf01e..57ee71430 100644 --- a/packages/app/src/utils/diff-layout.test.ts +++ b/packages/app/src/utils/diff-layout.test.ts @@ -4,7 +4,7 @@ import { buildSplitDiffRows, buildUnifiedDiffLines, } from "./diff-layout"; -import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query"; +import type { ParsedDiffFile } from "@/git/use-diff-query"; import type { ReviewableDiffTarget } from "./diff-layout"; function makeFile( diff --git a/packages/app/src/utils/diff-layout.ts b/packages/app/src/utils/diff-layout.ts index 7f5680cb2..e6fec45da 100644 --- a/packages/app/src/utils/diff-layout.ts +++ b/packages/app/src/utils/diff-layout.ts @@ -1,4 +1,4 @@ -import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query"; +import type { DiffLine, ParsedDiffFile } from "@/git/use-diff-query"; type ReviewSide = "old" | "new"; type ReviewableLineType = "add" | "remove" | "context"; diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts index 409318f60..3739ae457 100644 --- a/packages/app/src/utils/new-agent-routing.test.ts +++ b/packages/app/src/utils/new-agent-routing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; +import type { CheckoutStatusPayload } from "@/git/use-status-query"; import { parseAgentKey, resolveNewAgentWorkingDir, diff --git a/packages/app/src/utils/new-agent-routing.ts b/packages/app/src/utils/new-agent-routing.ts index 3785b1e4e..f7a2b106e 100644 --- a/packages/app/src/utils/new-agent-routing.ts +++ b/packages/app/src/utils/new-agent-routing.ts @@ -1,4 +1,4 @@ -import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; +import type { CheckoutStatusPayload } from "@/git/use-status-query"; import { parseHostWorkspaceOpenIntentFromPathname, parseHostAgentRouteFromPathname, diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 8ed87e37b..895c77e03 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -30,6 +30,8 @@ import type { CheckoutPullResponse, CheckoutPushResponse, CheckoutPrCreateResponse, + CheckoutPrMergeResponse, + CheckoutPrMergeMethod, CheckoutPrStatusResponse, PullRequestTimelineResponse, CheckoutSwitchBranchResponse, @@ -263,6 +265,7 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; type CheckoutPullPayload = CheckoutPullResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; +type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"]; @@ -2744,6 +2747,23 @@ export class DaemonClient { }); } + async checkoutPrMerge( + cwd: string, + input: { method: CheckoutPrMergeMethod }, + requestId?: string, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "checkout_pr_merge_request", + cwd, + mergeMethod: input.method, + }, + responseType: "checkout_pr_merge_response", + timeout: 60000, + }); + } + async checkoutPrStatus(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 65a6609d3..1a28b80ef 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -282,6 +282,7 @@ function createGitHubServiceStub(): GitHubService { number: 1, url: "https://github.com/acme/repo/pull/1", }), + mergePullRequest: async () => ({ success: true }), isAuthenticated: async () => true, invalidate: () => {}, }; diff --git a/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts new file mode 100644 index 000000000..36a097927 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts @@ -0,0 +1,318 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { execSync } from "child_process"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { z } from "zod"; + +import { + createDaemonTestContext, + createTempGithubRepoName, + type DaemonTestContext, +} from "../test-utils/index.js"; +import { createWorktree as createWorktreePrimitive } from "../../utils/worktree.js"; +import type { PullRequestMergeable } from "../../shared/messages.js"; + +const GhPrViewSchema = z.object({ + state: z.string(), + mergedAt: z.string().nullable(), + mergeCommit: z.object({ oid: z.string() }).nullable(), +}); + +type GhPrView = z.infer; + +const CODEX_TEST_MODEL = "gpt-5.4-mini"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; + +function tmpCwd(prefix: string): string { + return realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); +} + +function hasGitHubCliAuth(): boolean { + try { + execSync("gh auth status -h github.com", { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +function hasGitHubCliDeleteRepoScope(): boolean { + try { + const status = execSync("gh auth status -h github.com 2>&1", { stdio: "pipe" }).toString(); + const scopesLine = status.split(/\r?\n/).find((line) => /token[- ]scopes?:/i.test(line)); + const hasDeleteRepoScope = scopesLine?.includes("delete_repo") ?? false; + + if (!hasDeleteRepoScope) { + console.warn( + "Skipping checkout PR merge e2e: gh auth token is missing the delete_repo scope required to clean up temporary GitHub repos.", + ); + } + + return hasDeleteRepoScope; + } catch { + return false; + } +} + +const hasRequiredGitHubCliAuth = hasGitHubCliAuth() && hasGitHubCliDeleteRepoScope(); +const testWithGitHubCliAuth = hasRequiredGitHubCliAuth ? test : test.skip; + +function initGitRepo(repoDir: string): void { + execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.email 'paseo-test@example.com'", { + cwd: repoDir, + stdio: "pipe", + }); + execSync("git config user.name 'Paseo Test'", { + cwd: repoDir, + stdio: "pipe", + }); + writeFileSync(path.join(repoDir, "README.md"), "init\n"); + execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { + cwd: repoDir, + stdio: "pipe", + }); +} + +function getGhLogin(): string { + return execSync("gh api user --jq .login", { stdio: "pipe" }).toString().trim(); +} + +function createPrivateRepo(repoName: string): void { + execSync(`gh api -X POST user/repos -f name=${repoName} -f private=true`, { + stdio: "pipe", + }); +} + +function getGhToken(): string { + return execSync("gh auth token", { stdio: "pipe" }).toString().trim(); +} + +function formatCommandError(error: unknown): string { + if (error && typeof error === "object") { + const commandError = error as { stderr?: Buffer; stdout?: Buffer; message?: string }; + const output = [commandError.stderr, commandError.stdout] + .map((buffer) => buffer?.toString().trim()) + .filter(Boolean) + .join("\n"); + + return output || commandError.message || String(error); + } + + return String(error); +} + +function deleteRepoOrThrow(fullName: string | null): void { + if (!fullName) { + return; + } + + try { + execSync(`gh repo delete ${fullName} --yes`, { stdio: "pipe" }); + } catch (repoDeleteError) { + try { + execSync(`gh api -X DELETE repos/${fullName}`, { stdio: "pipe" }); + } catch (apiDeleteError) { + throw new Error( + `Failed to delete temporary GitHub repo ${fullName}. Clean it up manually. ` + + `gh repo delete failed: ${formatCommandError(repoDeleteError)}. ` + + `gh api DELETE failed: ${formatCommandError(apiDeleteError)}`, + { cause: apiDeleteError }, + ); + } + } +} + +function readGhPrView(prNumber: number, repoFullName: string): GhPrView { + return GhPrViewSchema.parse( + JSON.parse( + execSync(`gh pr view ${prNumber} -R ${repoFullName} --json state,mergedAt,mergeCommit`, { + stdio: "pipe", + }).toString(), + ), + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function pollForMergeable( + ctx: DaemonTestContext, + worktreePath: string, +): Promise { + const deadline = Date.now() + 30000; + let lastMergeable: PullRequestMergeable = "UNKNOWN"; + + while (Date.now() < deadline) { + const prStatus = await ctx.client.checkoutPrStatus(worktreePath); + expect(prStatus.error).toBeNull(); + expect(prStatus.githubFeaturesEnabled).toBe(true); + expect(prStatus.status).not.toBeNull(); + + if (prStatus.status?.mergeable === "MERGEABLE") { + return prStatus.status.number; + } + + lastMergeable = prStatus.status?.mergeable ?? "UNKNOWN"; + await sleep(1500); + } + + throw new Error(`Timed out waiting for GitHub PR to become MERGEABLE; last=${lastMergeable}`); +} + +function readFetchedFile(repoDir: string, filePath: string): string { + return execSync(`git show FETCH_HEAD:${filePath}`, { cwd: repoDir }).toString(); +} + +describe("daemon checkout PR merge loop", () => { + let ctx: DaemonTestContext; + + beforeEach(async () => { + ctx = await createDaemonTestContext(); + }); + + afterEach(async () => { + await ctx.cleanup(); + }, 60000); + + testWithGitHubCliAuth( + "squash-merges a GitHub PR via checkout RPCs", + async () => { + const repoDir = tmpCwd("checkout-pr-merge-"); + let repoFullName: string | null = null; + let agentId: string | null = null; + let worktreePath: string | null = null; + + let repoCleanupError: unknown; + try { + initGitRepo(repoDir); + + const owner = getGhLogin(); + const repoName = createTempGithubRepoName("checkout-pr-merge"); + repoFullName = `${owner}/${repoName}`; + createPrivateRepo(repoName); + + const token = encodeURIComponent(getGhToken()); + execSync( + `git remote add origin https://x-access-token:${token}@github.com/${repoFullName}.git`, + { + cwd: repoDir, + stdio: "pipe", + }, + ); + execSync("git push -u origin main", { cwd: repoDir, stdio: "pipe" }); + + const worktree = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "merge-pr-squash", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "merge-pr-squash", + }, + runSetup: true, + paseoHome: ctx.daemon.paseoHome, + }); + worktreePath = worktree.worktreePath; + + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: worktree.worktreePath, + title: "Checkout PR Merge", + }); + agentId = agent.id; + + writeFileSync(path.join(worktree.worktreePath, "feature.txt"), "squash merge\n"); + + const commitResult = await ctx.client.checkoutCommit(worktree.worktreePath, { + message: "Add squash merge fixture", + addAll: true, + }); + expect(commitResult.error).toBeNull(); + expect(commitResult.success).toBe(true); + + const pushResult = await ctx.client.checkoutPush(worktree.worktreePath); + expect(pushResult.error).toBeNull(); + expect(pushResult.success).toBe(true); + + const prCreate = await ctx.client.checkoutPrCreate(worktree.worktreePath, { + title: "Add squash merge fixture", + body: "Created by the checkout PR merge e2e test.", + baseRef: "main", + }); + expect(prCreate.error).toBeNull(); + expect(prCreate.url).toContain(repoName); + expect(prCreate.number).not.toBeNull(); + + if (prCreate.number === null) { + throw new Error("checkoutPrCreate returned success without a PR number"); + } + const prNumber = prCreate.number; + + const mergeablePrNumber = await pollForMergeable(ctx, worktree.worktreePath); + expect(mergeablePrNumber).toBe(prNumber); + + const mergeResult = await ctx.client.checkoutPrMerge(worktree.worktreePath, { + method: "squash", + }); + expect(mergeResult.error).toBeNull(); + expect(mergeResult.success).toBe(true); + + const prView = readGhPrView(prNumber, repoFullName); + expect(prView).toEqual({ + state: "MERGED", + mergedAt: expect.any(String), + mergeCommit: { + oid: expect.any(String), + }, + }); + + execSync("git fetch origin main", { + cwd: repoDir, + stdio: "pipe", + }); + execSync("git merge-base --is-ancestor main FETCH_HEAD", { + cwd: repoDir, + stdio: "pipe", + }); + expect(readFetchedFile(repoDir, "feature.txt")).toBe("squash merge\n"); + + const archiveResult = await ctx.client.archivePaseoWorktree({ + worktreePath: worktree.worktreePath, + }); + expect(archiveResult.error).toBeNull(); + expect(archiveResult.success).toBe(true); + expect(existsSync(worktree.worktreePath)).toBe(false); + worktreePath = null; + + const remainingAgents = await ctx.client.fetchAgents(); + expect(remainingAgents.entries.some((entry) => entry.agent.id === agent.id)).toBe(false); + agentId = null; + } finally { + if (worktreePath) { + await ctx.client.archivePaseoWorktree({ worktreePath }).catch(() => undefined); + } + if (agentId) { + await ctx.client.deleteAgent(agentId).catch(() => undefined); + } + try { + deleteRepoOrThrow(repoFullName); + } catch (error) { + repoCleanupError = error; + } + rmSync(repoDir, { recursive: true, force: true }); + } + if (repoCleanupError) { + throw repoCleanupError; + } + }, + 180000, + ); +}); diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index 517c08365..235578fe6 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -4,7 +4,11 @@ import { tmpdir } from "os"; import path from "path"; import { execSync } from "child_process"; -import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; +import { + createDaemonTestContext, + createTempGithubRepoName, + type DaemonTestContext, +} from "../test-utils/index.js"; import { createWorktree as createWorktreePrimitive, type CreateWorktreeOptions, @@ -76,11 +80,6 @@ function initGitRepo(repoDir: string): void { }); } -function createTempRepoName(): string { - const rand = Math.random().toString(16).slice(2, 8); - return `paseo-checkout-ship-${Date.now()}-${rand}`; -} - function getGhLogin(): string { return execSync("gh api user --jq .login", { stdio: "pipe" }).toString().trim(); } @@ -128,7 +127,7 @@ describe("daemon checkout ship loop", () => { initGitRepo(repoDir); const owner = getGhLogin(); - const repoName = createTempRepoName(); + const repoName = createTempGithubRepoName("checkout-ship"); repoFullName = `${owner}/${repoName}`; createPrivateRepo(repoName); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 6bdf5e4a6..75efdf919 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -498,6 +498,7 @@ function createGitHubServiceStub(): GitHubService { number: 1, url: "https://github.com/acme/repo/pull/1", }), + mergePullRequest: async () => ({ success: true }), isAuthenticated: async () => true, invalidate: () => {}, }; diff --git a/packages/server/src/server/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index ec5b38cd5..7381f7658 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -42,6 +42,7 @@ function createResolverHarness(): ResolverHarness { number: 1, url: "https://github.com/acme/repo/pull/1", }), + mergePullRequest: async () => ({ success: true }), isAuthenticated: async () => true, invalidate: () => {}, }; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 864d7f0b1..0051b8d93 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -65,6 +65,7 @@ interface SessionHandlerInternals { handleCheckoutMergeFromBaseRequest(params: unknown): Promise; handleCheckoutCommitRequest(params: unknown): Promise; handleCheckoutPrCreateRequest(params: unknown): Promise; + handleCheckoutPrMergeRequest(params: unknown): Promise; handleCheckoutPullRequest(params: unknown): Promise; handleCheckoutPushRequest(params: unknown): Promise; handleCheckoutStatusRequest(params: unknown): Promise; @@ -288,6 +289,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { invalidate: vi.fn(), searchIssuesAndPrs: vi.fn(), createPullRequest: vi.fn(), + mergePullRequest: vi.fn(), }; const checkoutDiffManager = options.checkoutDiffManager ?? { scheduleRefreshForCwd: vi.fn(), @@ -1022,6 +1024,7 @@ describe("session PR status payload normalization", () => { headRefName: "feature/pr-pane", isMerged: false, isDraft: true, + mergeable: "MERGEABLE", checks: [ { name: "typecheck", @@ -1037,6 +1040,7 @@ describe("session PR status payload normalization", () => { expect(payload).toHaveProperty("repoOwner", "internal-owner"); expect(payload).toHaveProperty("repoName", "internal-repo"); + expect(payload).toHaveProperty("mergeable", "MERGEABLE"); expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload); }); }); @@ -1699,6 +1703,91 @@ describe("session checkout pull request creation", () => { }); }); +describe("session checkout pull request merge", () => { + test("merges the current pull request and refreshes GitHub state", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + mergePullRequest: vi.fn().mockResolvedValue({ success: true }), + }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue({ + github: { + pullRequest: { + number: 42, + }, + }, + }), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutPrMergeRequest({ + type: "checkout_pr_merge_request", + cwd: "/tmp/request-worktree", + mergeMethod: "squash", + requestId: "request-pr-merge", + }); + + expect(github.mergePullRequest).toHaveBeenCalledWith({ + cwd: "/tmp/request-worktree", + prNumber: 42, + mergeMethod: "squash", + }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + reason: "merge-pr", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(messages).toContainEqual({ + type: "checkout_pr_merge_response", + payload: { + cwd: "/tmp/request-worktree", + success: true, + error: null, + requestId: "request-pr-merge", + }, + }); + }); + + test("surfaces merge errors verbatim", async () => { + const messages: unknown[] = []; + const github = { + invalidate: vi.fn(), + mergePullRequest: vi.fn().mockRejectedValue(new Error("base branch has conflicts")), + }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue({ + github: { + pullRequest: { + number: 42, + }, + }, + }), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + + await asSessionInternals(session).handleCheckoutPrMergeRequest({ + type: "checkout_pr_merge_request", + cwd: "/tmp/request-worktree", + mergeMethod: "merge", + requestId: "request-pr-merge-failure", + }); + + expect(messages).toContainEqual({ + type: "checkout_pr_merge_response", + payload: { + cwd: "/tmp/request-worktree", + success: false, + error: { + code: "UNKNOWN", + message: "base branch has conflicts", + }, + requestId: "request-pr-merge-failure", + }, + }); + }); +}); + describe("session checkout pull and push handling", () => { test("forces workspace git and GitHub refresh after pulling", async () => { const messages: unknown[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1aa918b7f..d9e413e41 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -270,6 +270,7 @@ type GitMutationRefreshReason = | "push" | "merge-to-base" | "merge-from-base" + | "merge-pr" | "create-pr" | "switch-branch" | "create-branch" @@ -2011,6 +2012,7 @@ export class Session { }); } + // eslint-disable-next-line complexity private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "checkout_status_request": @@ -2046,6 +2048,8 @@ export class Session { return this.handleCheckoutPushRequest(msg); case "checkout_pr_create_request": return this.handleCheckoutPrCreateRequest(msg); + case "checkout_pr_merge_request": + return this.handleCheckoutPrMergeRequest(msg); case "checkout_pr_status_request": return this.handleCheckoutPrStatusRequest(msg); case "pull_request_timeline_request": @@ -5282,6 +5286,47 @@ export class Session { } } + private async handleCheckoutPrMergeRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + const snapshot = await this.workspaceGitService.getSnapshot(cwd); + const prNumber = snapshot.github.pullRequest?.number; + if (typeof prNumber !== "number") { + throw new Error("Unable to determine GitHub pull request number for merge"); + } + + await this.github.mergePullRequest({ + cwd, + prNumber, + mergeMethod: msg.mergeMethod, + }); + await this.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true }); + + this.emit({ + type: "checkout_pr_merge_response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_pr_merge_response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + private async handleCheckoutPrStatusRequest( msg: Extract, ): Promise { @@ -8755,6 +8800,7 @@ export function normalizeCheckoutPrStatusPayload( headRefName: status.headRefName, isMerged: status.isMerged, isDraft: status.isDraft ?? false, + mergeable: status.mergeable ?? "UNKNOWN", checks: status.checks ?? [], checksStatus: status.checksStatus, reviewDecision: status.reviewDecision, diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 8a68fb41a..03666d007 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -472,6 +472,7 @@ describe("workspace git watch targets", () => { baseRefName: "main", headRefName: "workspace-git-service", isMerged: false, + mergeable: "UNKNOWN", isDraft: false, checks: [ { @@ -531,6 +532,7 @@ describe("workspace git watch targets", () => { baseRefName: "main", headRefName: "workspace-git-service", isMerged: true, + mergeable: "UNKNOWN", isDraft: false, checks: [], checksStatus: undefined, diff --git a/packages/server/src/server/test-utils/index.ts b/packages/server/src/server/test-utils/index.ts index 56832c7e9..ceedc338d 100644 --- a/packages/server/src/server/test-utils/index.ts +++ b/packages/server/src/server/test-utils/index.ts @@ -9,3 +9,4 @@ export { } from "./daemon-client.js"; export { createDaemonTestContext, type DaemonTestContext } from "./daemon-test-context.js"; export { useTempClaudeConfigDir } from "./claude-config.js"; +export { TEMP_GITHUB_REPO_PREFIX, createTempGithubRepoName } from "./temp-github-repo.js"; diff --git a/packages/server/src/server/test-utils/temp-github-repo.ts b/packages/server/src/server/test-utils/temp-github-repo.ts new file mode 100644 index 000000000..a6d47988c --- /dev/null +++ b/packages/server/src/server/test-utils/temp-github-repo.ts @@ -0,0 +1,9 @@ +// Single namespace for temporary GitHub repos created by Paseo tests. +// Bulk cleanup relies on this prefix being unmistakable — never reuse `paseo-` +// (collides with real repos like `paseo`, `paseo-website`). +export const TEMP_GITHUB_REPO_PREFIX = "paseotmp-"; + +export function createTempGithubRepoName(category: string): string { + const rand = Math.random().toString(16).slice(2, 8); + return `${TEMP_GITHUB_REPO_PREFIX}${category}-${Date.now()}-${rand}`; +} diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index cdfcf1203..e31c28831 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -193,6 +193,7 @@ function createGitHubServiceStub(): GitHubService { url: "https://github.com/acme/repo/pull/1", number: 1, })), + mergePullRequest: vi.fn(async () => ({ success: true })), isAuthenticated: vi.fn(async () => true), invalidate: vi.fn(), }; diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 86f29d190..2183e4630 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -176,6 +176,7 @@ function createGitHubServiceStub(): GitHubService { url: "https://github.com/acme/repo/pull/1", number: 1, })), + mergePullRequest: vi.fn(async () => ({ success: true })), isAuthenticated: vi.fn(async () => true), invalidate: vi.fn(), }; diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index e949728c3..7e6e5ee25 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -20,7 +20,11 @@ import { resolveBranchCheckout, resolveAbsoluteGitDir, } from "../utils/checkout-git.js"; -import { createGitHubService, type GitHubService } from "../services/github-service.js"; +import { + createGitHubService, + type GitHubService, + type PullRequestMergeable, +} from "../services/github-service.js"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; import { runGitCommand } from "../utils/run-git-command.js"; import { resolveGitHubRemote, type GitHubRemoteIdentity } from "../utils/github-remote.js"; @@ -78,6 +82,7 @@ export interface WorkspaceGitRuntimeSnapshot { headRefName: string; isMerged: boolean; isDraft?: boolean; + mergeable?: PullRequestMergeable; checks?: Array<{ name: string; status: "success" | "failure" | "pending" | "skipped" | "cancelled"; diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index f328791f7..0a412938c 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -39,6 +39,7 @@ function createGitHubServiceStub(): GitHubService { number: 1, url: "https://github.com/acme/repo/pull/1", }), + mergePullRequest: async () => ({ success: true }), isAuthenticated: async () => true, invalidate: () => {}, }; diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index ff4226a04..83e3c8aa4 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -149,6 +149,7 @@ function createGitHubServiceStub(): GitHubService { number: 1, url: "https://github.com/acme/repo/pull/1", }), + mergePullRequest: async () => ({ success: true }), isAuthenticated: async () => true, invalidate: () => {}, }; diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index 214d4eb00..15cf5989c 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -24,6 +24,7 @@ const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000; interface RunnerCall { args: string[]; cwd: string; + envOverlay?: Record; } interface TestRunner { @@ -46,7 +47,7 @@ function createRunner(stdoutByCall: string[]): TestRunner { return { calls, runner: async (args: string[], options: GitHubCommandRunnerOptions) => { - calls.push({ args, cwd: options.cwd }); + calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay }); const stdout = stdoutByCall.shift() ?? "[]"; return { stdout, stderr: "" }; }, @@ -60,7 +61,7 @@ function createScriptedRunner(steps: RunnerStep[]): TestRunner { return { calls, runner: async (args: string[], options: GitHubCommandRunnerOptions) => { - calls.push({ args, cwd: options.cwd }); + calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay }); const step = steps.shift() ?? ""; if (typeof step === "string") { return { stdout: step, stderr: "" }; @@ -81,7 +82,7 @@ function createDeferredRunner(): TestRunner { return { calls, runner: (args: string[], options: GitHubCommandRunnerOptions) => { - calls.push({ args, cwd: options.cwd }); + calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay }); return new Promise((resolve) => { resolveNext = (stdout: string) => resolve({ stdout, stderr: "" }); }); @@ -125,6 +126,7 @@ function createCurrentPullRequestStatus( headRefName: "feature", isMerged: false, isDraft: false, + mergeable: "MERGEABLE", checks: [], checksStatus: "none", reviewDecision: null, @@ -284,6 +286,33 @@ describe("GitHubService", () => { vi.useRealTimers(); }); + it.each([ + ["merge", ["pr", "merge", "42", "--merge"]], + ["squash", ["pr", "merge", "42", "--squash"]], + ["rebase", ["pr", "merge", "42", "--rebase"]], + ] as const)("merges pull requests with gh using %s", async (mergeMethod, expectedArgs) => { + const runner = createRunner([""]); + const service = createGitHubService({ + runner: runner.runner, + }); + + await expect( + service.mergePullRequest({ + cwd: "/tmp/repo", + prNumber: 42, + mergeMethod, + }), + ).resolves.toEqual({ success: true }); + + expect(runner.calls).toEqual([ + { + args: expectedArgs, + cwd: "/tmp/repo", + envOverlay: { GH_PROMPT_DISABLED: "1" }, + }, + ]); + }); + it("computes fast cadence for pending and slow cadence for stable PR states", () => { const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" }); const runningCheckStatus = createCurrentPullRequestStatus({ @@ -1048,7 +1077,7 @@ describe("GitHubService", () => { "pr", "view", "--json", - "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner", + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner", ]); expect(status).toEqual({ number: 42, @@ -1061,6 +1090,7 @@ describe("GitHubService", () => { headRefName: "feature/pr-pane", isMerged: false, isDraft: true, + mergeable: "UNKNOWN", checks: [ { name: "server-tests", @@ -1080,6 +1110,25 @@ describe("GitHubService", () => { }); }); + it("defaults unexpected PR mergeability values to unknown", async () => { + const runner = createRunner([ + currentPullRequestJson({ + mergeable: "", + }), + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + }); + + expect(status?.mergeable).toBe("UNKNOWN"); + }); + it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => { const runner = createScriptedRunner([ currentPullRequestJson({ @@ -1145,7 +1194,7 @@ describe("GitHubService", () => { "pr", "view", "--json", - "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner", + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner", ], ["repo", "view", "--json", "owner,name,parent"], [ @@ -1158,7 +1207,7 @@ describe("GitHubService", () => { "--head", "forkOwner:feature/fork", "--json", - "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner", + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner", "--limit", "10", ], @@ -1218,7 +1267,7 @@ describe("GitHubService", () => { "pr", "view", "--json", - "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner", + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner", ], ["repo", "view", "--json", "owner,name,parent"], ]); @@ -1279,7 +1328,7 @@ describe("GitHubService", () => { "pr", "view", "--json", - "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner", + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner", ], [ "pr", @@ -1289,7 +1338,7 @@ describe("GitHubService", () => { "--head", "main", "--json", - "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner", + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner", "--limit", "10", ], @@ -1452,6 +1501,7 @@ describe("GitHubService", () => { headRefName: "feature", isMerged: false, isDraft: true, + mergeable: "UNKNOWN", checks: [ { name: "server-tests", diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index 99ebb84c9..4ab98dd0f 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -91,6 +91,8 @@ const HeadRepositoryOwnerSchema = z .nullable() .optional(); +const PullRequestMergeableSchema = z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN"); + const CurrentPullRequestStatusSchema = z.object({ number: z.number().optional(), url: z.string().catch(""), @@ -102,6 +104,7 @@ const CurrentPullRequestStatusSchema = z.object({ mergedAt: z.string().nullable().optional(), statusCheckRollup: z.unknown().optional(), reviewDecision: z.unknown().optional(), + mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"), headRepositoryOwner: HeadRepositoryOwnerSchema, }); @@ -234,7 +237,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!) }`; const CURRENT_PR_STATUS_FIELDS = - "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner"; + "number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner"; const PULL_REQUEST_TIMELINE_QUERY = ` query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) { @@ -290,6 +293,7 @@ interface GitHubServiceDependencies { export interface GitHubCommandRunnerOptions { cwd: string; + envOverlay?: Record; } export interface GitHubCommandResult { @@ -346,6 +350,7 @@ export interface PullRequestCheck { export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure"; export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null; +export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN"; export interface GitHubCurrentPullRequestStatus { number?: number; @@ -358,6 +363,7 @@ export interface GitHubCurrentPullRequestStatus { headRefName: string; isMerged: boolean; isDraft?: boolean; + mergeable: PullRequestMergeable; checks: PullRequestCheck[]; checksStatus: PullRequestChecksStatus; reviewDecision: PullRequestReviewDecision; @@ -404,6 +410,18 @@ export interface GitHubPullRequestCreateResult { number: number; } +export type GitHubPullRequestMergeMethod = "merge" | "squash" | "rebase"; + +export interface MergeGitHubPullRequestOptions { + cwd: string; + prNumber: number; + mergeMethod: GitHubPullRequestMergeMethod; +} + +export interface GitHubPullRequestMergeResult { + success: true; +} + export type GitHubReadOptions = | { force?: false; @@ -492,6 +510,7 @@ export interface GitHubService { createPullRequest( options: CreateGitHubPullRequestOptions, ): Promise; + mergePullRequest(options: MergeGitHubPullRequestOptions): Promise; isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise; retainCurrentPullRequestStatusPoll?(options: { cwd: string; @@ -1030,6 +1049,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G return parsed; }, + async mergePullRequest(input) { + await run(["pr", "merge", String(input.prNumber), `--${input.mergeMethod}`], { + cwd: input.cwd, + envOverlay: { GH_PROMPT_DISABLED: "1" }, + }); + return { success: true }; + }, + isAuthenticated(input) { return cached({ cwd: input.cwd, @@ -1167,7 +1194,7 @@ async function runGhCommand( ): Promise { return execCommand("gh", args, { cwd: options.cwd, - envOverlay: GITHUB_ENV, + envOverlay: { ...GITHUB_ENV, ...options.envOverlay }, maxBuffer: 10 * 1024 * 1024, }); } @@ -1690,6 +1717,7 @@ function toCurrentPullRequestStatus( headRefName: item.headRefName || fallbackHeadRefName, isMerged: mergedAt !== null, isDraft: item.isDraft ?? false, + mergeable: item.mergeable, checks, checksStatus: computeChecksStatus(checks), reviewDecision: mapReviewDecision(item.reviewDecision), diff --git a/packages/server/src/shared/messages.checkout-pr-schema.test.ts b/packages/server/src/shared/messages.checkout-pr-schema.test.ts new file mode 100644 index 000000000..c440b95a1 --- /dev/null +++ b/packages/server/src/shared/messages.checkout-pr-schema.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; + +import { CheckoutPrMergeRequestSchema, CheckoutPrStatusSchema } from "./messages.js"; + +describe("checkout PR schemas", () => { + test("parses PR status payloads without mergeability", () => { + expect( + CheckoutPrStatusSchema.parse({ + number: 42, + url: "https://github.com/getpaseo/paseo/pull/42", + title: "Ship it", + state: "open", + baseRefName: "main", + headRefName: "feature/ship-it", + isMerged: false, + }), + ).toMatchObject({ + number: 42, + mergeable: "UNKNOWN", + }); + }); + + test.each(["merge", "squash", "rebase"] as const)( + "accepts %s as a PR merge method", + (mergeMethod) => { + expect( + CheckoutPrMergeRequestSchema.parse({ + type: "checkout_pr_merge_request", + cwd: "/tmp/repo", + mergeMethod, + requestId: "request-merge-pr", + }), + ).toMatchObject({ mergeMethod }); + }, + ); + + test("rejects unknown PR merge methods", () => { + expect(() => + CheckoutPrMergeRequestSchema.parse({ + type: "checkout_pr_merge_request", + cwd: "/tmp/repo", + mergeMethod: "auto", + requestId: "request-merge-pr", + }), + ).toThrow(); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index e780af3b8..cd191fe94 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1272,6 +1272,13 @@ export const CheckoutPrCreateRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutPrMergeRequestSchema = z.object({ + type: z.literal("checkout_pr_merge_request"), + cwd: z.string(), + mergeMethod: z.enum(["merge", "squash", "rebase"]), + requestId: z.string(), +}); + export const CheckoutPrStatusRequestSchema = z.object({ type: z.literal("checkout_pr_status_request"), cwd: z.string(), @@ -1719,6 +1726,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPullRequestSchema, CheckoutPushRequestSchema, CheckoutPrCreateRequestSchema, + CheckoutPrMergeRequestSchema, CheckoutPrStatusRequestSchema, PullRequestTimelineRequestSchema, CheckoutSwitchBranchRequestSchema, @@ -2661,6 +2669,11 @@ export const CheckoutPrStatusSchema = z.object({ headRefName: z.string(), isMerged: z.boolean(), isDraft: z.boolean().optional().default(false), + mergeable: z + .enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]) + .catch("UNKNOWN") + .optional() + .default("UNKNOWN"), checks: z .array( z.object({ @@ -2782,6 +2795,16 @@ export const CheckoutPrCreateResponseSchema = z.object({ }), }); +export const CheckoutPrMergeResponseSchema = z.object({ + type: z.literal("checkout_pr_merge_response"), + payload: z.object({ + cwd: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const CheckoutPrStatusResponseSchema = z.object({ type: z.literal("checkout_pr_status_response"), payload: CheckoutPrStatusPayloadSchema, @@ -3338,6 +3361,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPullResponseSchema, CheckoutPushResponseSchema, CheckoutPrCreateResponseSchema, + CheckoutPrMergeResponseSchema, CheckoutPrStatusResponseSchema, PullRequestTimelineResponseSchema, CheckoutSwitchBranchResponseSchema, @@ -3585,6 +3609,10 @@ export type CheckoutPushRequest = z.infer; export type CheckoutPushResponse = z.infer; export type CheckoutPrCreateRequest = z.infer; export type CheckoutPrCreateResponse = z.infer; +export type CheckoutPrMergeRequest = z.infer; +export type CheckoutPrMergeResponse = z.infer; +export type CheckoutPrMergeMethod = z.infer["mergeMethod"]; +export type PullRequestMergeable = z.infer["mergeable"]; export type CheckoutPrStatusRequest = z.infer; export type CheckoutPrStatusResponse = z.infer; export type PullRequestTimelineRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 7d7e73849..086e7a7d3 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -124,6 +124,7 @@ function createGitHubServiceForStatus( url: "https://github.com/getpaseo/paseo/pull/1", number: 1, }), + mergePullRequest: async () => ({ success: true }), isAuthenticated: async () => true, invalidate: () => {}, }; diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 30bd4e358..1f6dbbea5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -12,6 +12,7 @@ import { createGitHubService, resolveGitHubRepo, type GitHubService, + type PullRequestMergeable, } from "../services/github-service.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand } from "./run-git-command.js"; @@ -2309,6 +2310,7 @@ export interface PullRequestStatus { headRefName: string; isMerged: boolean; isDraft?: boolean; + mergeable?: PullRequestMergeable; checks?: PullRequestCheck[]; checksStatus?: ChecksStatus; reviewDecision?: ReviewDecision;