feat(checkout): merge PR action with real-GitHub e2e test (#814)

* 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.
This commit is contained in:
Mohamed Boudra
2026-05-09 15:39:26 +08:00
committed by GitHub
parent c097678cdf
commit ed07452c80
63 changed files with 1204 additions and 907 deletions
@@ -82,7 +82,7 @@ test.describe("Composer attachments", () => {
} }
const ghRepo = await createTempGithubRepo({ const ghRepo = await createTempGithubRepo({
prefix: "paseo-e2e-attach-issue-", category: "attach-issue",
issues: [{ title: "fix: attach-issue-unique-alpha" }], issues: [{ title: "fix: attach-issue-unique-alpha" }],
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }], prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
}); });
@@ -114,7 +114,7 @@ test.describe("Composer attachments", () => {
} }
const ghRepo = await createTempGithubRepo({ const ghRepo = await createTempGithubRepo({
prefix: "paseo-e2e-attach-pr-", category: "attach-pr",
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }], prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
}); });
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath); const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
+8 -3
View File
@@ -153,14 +153,19 @@ function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture
return { number: issueNumber, title: spec.title, url: issueUrl }; 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: { export async function createTempGithubRepo(options: {
prefix?: string; category: string;
prs?: PrSpec[]; prs?: PrSpec[];
issues?: IssueSpec[]; issues?: IssueSpec[];
}): Promise<GhRepoFixture> { }): Promise<GhRepoFixture> {
const { prefix = "paseo-e2e-", prs = [], issues = [] } = options; const { category, prs = [], issues = [] } = options;
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; 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 // Bootstrap local git repo
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`)); const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test"; 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<void> { export async function openPrPane(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open explorer" }).click(); await page.getByRole("button", { name: "Open explorer" }).click();
+1 -1
View File
@@ -28,7 +28,7 @@ test.describe("PR pane", () => {
seedClient = await connectWorkspaceSetupClient(); seedClient = await connectWorkspaceSetupClient();
repoFixture = await createTempGithubRepo({ repoFixture = await createTempGithubRepo({
prefix: "paseo-e2e-pr-", category: "pr-pane",
prs: [ prs: [
{ title: "Review selected start ref", state: "open" }, { title: "Review selected start ref", state: "open" },
{ title: "Merged feature branch", state: "merged" }, { title: "Merged feature branch", state: "merged" },
@@ -13,7 +13,7 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon"; 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 { usePrPaneData } from "@/hooks/use-pr-pane-data";
import { import {
usePanelStore, usePanelStore,
@@ -24,7 +24,7 @@ import {
} from "@/stores/panel-store"; } from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; 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 { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
@@ -81,7 +81,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { SyncedLoader } from "@/components/synced-loader"; import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context"; 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 { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
@@ -95,7 +95,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; 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 { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
@@ -27,7 +27,7 @@ import { Pressable } from "react-native";
import { Portal } from "@gorhom/portal"; import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; 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 { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list"; import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
@@ -19,7 +19,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/components/git-actions-policy"; import type { GitAction, GitActions } from "@/git/policy";
interface GitActionsSplitButtonProps { interface GitActionsSplitButtonProps {
gitActions: GitActions; gitActions: GitActions;
@@ -163,9 +163,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
action={action} action={action}
onSelect={handleActionSelect} onSelect={handleActionSelect}
archiveShortcutKeys={archiveShortcutKeys} archiveShortcutKeys={archiveShortcutKeys}
needsSeparator={ needsSeparator={action.startsGroup}
action.id === "merge-from-base" || action.id === "archive-worktree"
}
showSeparator={index > 0} showSeparator={index > 0}
closeOnSelect={ closeOnSelect={
action.status === "idle" && action.id === "pr" && action.label === "View PR" action.status === "idle" && action.id === "pr" && action.label === "View PR"
@@ -9,7 +9,7 @@ import {
invalidateCheckoutGitQueriesForClient, invalidateCheckoutGitQueriesForClient,
isLocalWorktreeArchivePending, isLocalWorktreeArchivePending,
useCheckoutGitActionsStore, useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store"; } from "@/git/actions-store";
import { import {
clearWorkspaceArchivePending, clearWorkspaceArchivePending,
isWorkspaceArchivePending, isWorkspaceArchivePending,
@@ -1,4 +1,5 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
import { create } from "zustand"; import { create } from "zustand";
import { queryClient as appQueryClient } from "@/query/query-client"; import { queryClient as appQueryClient } from "@/query/query-client";
import { import {
@@ -27,6 +28,9 @@ export type CheckoutGitAsyncActionId =
| "push" | "push"
| "pull-and-push" | "pull-and-push"
| "create-pr" | "create-pr"
| "merge-pr-squash"
| "merge-pr-merge"
| "merge-pr-rebase"
| "merge-branch" | "merge-branch"
| "merge-from-base" | "merge-from-base"
| "archive-worktree"; | "archive-worktree";
@@ -259,6 +263,11 @@ interface CheckoutGitActionsStoreState {
push: (params: { serverId: string; cwd: string }) => Promise<void>; push: (params: { serverId: string; cwd: string }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>; pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>; createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergePr: (params: {
serverId: string;
cwd: string;
method: CheckoutPrMergeMethod;
}) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>; mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: { archiveWorktree: (params: {
@@ -405,6 +414,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
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 }) => { mergeBranch: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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) { function createFile(path: string, additions = 0) {
return { return {
@@ -25,7 +25,6 @@ import {
type ViewStyle, type ViewStyle,
type TextStyle, type TextStyle,
} from "react-native"; } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
@@ -45,20 +44,16 @@ import {
Upload, Upload,
WrapText, WrapText,
} from "lucide-react-native"; } from "lucide-react-native";
import {
useCheckoutGitActionsStore,
type CheckoutGitActionStatus,
} from "@/stores/checkout-git-actions-store";
import { import {
useCheckoutDiffQuery, useCheckoutDiffQuery,
type ParsedDiffFile, type ParsedDiffFile,
type DiffLine, type DiffLine,
type HighlightToken, type HighlightToken,
} from "@/hooks/use-checkout-diff-query"; } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { DiffScroll } from "./diff-scroll"; import { DiffScroll } from "@/components/diff-scroll";
import { import {
darkHighlightColors, darkHighlightColors,
lightHighlightColors, lightHighlightColors,
@@ -66,7 +61,7 @@ import {
} from "@getpaseo/highlight"; } from "@getpaseo/highlight";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
import { import {
buildSplitDiffRows, buildSplitDiffRows,
buildUnifiedDiffLines, buildUnifiedDiffLines,
@@ -83,16 +78,12 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets"; import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { GitActionsSplitButton } from "@/git/actions-split-button";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; import { useGitActions } from "@/git/use-actions";
import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { useToast } from "@/contexts/toast-context";
import { import {
formatDiffContentText, formatDiffContentText,
formatDiffGutterText, formatDiffGutterText,
@@ -120,11 +111,7 @@ import {
type InlineReviewActions, type InlineReviewActions,
} from "@/review"; } from "@/review";
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);
}
function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) { function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
return [styles.fileHeader, pressed && styles.fileHeaderPressed]; return [styles.fileHeader, pressed && styles.fileHeaderPressed];
@@ -1274,16 +1261,6 @@ function getDiffContentLength(file: ParsedDiffFile): number {
return contentLength; 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( function computeEmptyMessage(
hideWhitespace: boolean, hideWhitespace: boolean,
diffMode: "uncommitted" | "base", diffMode: "uncommitted" | "base",
@@ -1411,194 +1388,6 @@ function DiffBodyContent({
); );
} }
interface GitActionRunners {
runCommit: (args: { serverId: string; cwd: string }) => Promise<void>;
runPull: (args: { serverId: string; cwd: string }) => Promise<void>;
runPush: (args: { serverId: string; cwd: string }) => Promise<void>;
runPullAndPush: (args: { serverId: string; cwd: string }) => Promise<void>;
runCreatePr: (args: { serverId: string; cwd: string }) => Promise<void>;
runMergeBranch: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
runMergeFromBase: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
runArchiveWorktree: (args: {
serverId: string;
cwd: string;
worktreePath: string;
}) => Promise<void>;
}
interface GitActionHandlersDeps {
serverId: string;
cwd: string;
baseRef: string | undefined;
status: ReturnType<typeof useCheckoutStatusQuery>["status"];
runners: GitActionRunners;
persistShipDefault: (next: "merge" | "pr") => Promise<void>;
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 { interface DeriveStatusStateInputs {
status: ReturnType<typeof useCheckoutStatusQuery>["status"]; status: ReturnType<typeof useCheckoutStatusQuery>["status"];
isStatusLoading: boolean; 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 { function computeBaseRefLabel(baseRef: string | undefined): string {
if (!baseRef) return "base"; if (!baseRef) return "base";
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
@@ -1687,36 +1447,6 @@ function computeCommittedDiffDescription(
return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`; 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( function computePrErrorMessage(
githubFeaturesEnabled: boolean, githubFeaturesEnabled: boolean,
prPayloadError: { message?: string } | null | undefined, 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: <GitCommitHorizontal size={16} color={iconColor} />,
handler: handlers.handleCommit,
},
pull: {
disabled: disabled.pullDisabled,
status: statuses.pullStatus,
icon: <Download size={16} color={iconColor} />,
handler: handlers.handlePull,
},
push: {
disabled: disabled.pushDisabled,
status: statuses.pushStatus,
icon: <Upload size={16} color={iconColor} />,
handler: handlers.handlePush,
},
"pull-and-push": {
disabled: disabled.pullAndPushDisabled,
status: statuses.pullAndPushStatus,
icon: <ArrowDownUp size={16} color={iconColor} />,
handler: handlers.handlePullAndPush,
},
pr: {
disabled: disabled.prDisabled,
status: policy.hasPullRequest ? "idle" : statuses.prCreateStatus,
icon: <GitHubIcon size={16} color={iconColor} />,
handler: handlers.handlePr,
},
"merge-branch": {
disabled: disabled.mergeDisabled,
status: statuses.mergeStatus,
icon: <GitMerge size={16} color={iconColor} />,
handler: handlers.handleMergeBranch,
},
"merge-from-base": {
disabled: disabled.mergeFromBaseDisabled,
status: statuses.mergeFromBaseStatus,
icon: <RefreshCcw size={16} color={iconColor} />,
handler: handlers.handleMergeFromBase,
},
"archive-worktree": {
disabled: disabled.archiveDisabled,
status: statuses.archiveStatus,
icon: <Archive size={16} color={iconColor} />,
handler: handlers.handleArchiveWorktree,
},
},
});
}
function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean { function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean {
return input.paneEnabled && input.isGit; return input.paneEnabled && input.isGit;
} }
@@ -1870,13 +1478,10 @@ export function GitDiffPane({
enabled, enabled,
}: GitDiffPaneProps) { }: GitDiffPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile;
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null); const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences(); useChangesPreferences();
const wrapLines = changesPreferences.wrapLines; const wrapLines = changesPreferences.wrapLines;
@@ -1943,15 +1548,7 @@ export function GitDiffPane({
error: statusError, error: statusError,
} = useCheckoutStatusQuery({ serverId, cwd }); } = useCheckoutStatusQuery({ serverId, cwd });
const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError }); const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError });
const { const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState;
gitStatus,
isGit,
notGit,
statusErrorMessage,
baseRef,
hasUncommittedChanges,
actionsDisabled,
} = statusState;
// Auto-select diff mode based on state: uncommitted when dirty, base when clean // Auto-select diff mode based on state: uncommitted when dirty, base when clean
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base"; const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
@@ -2041,11 +1638,7 @@ export function GitDiffPane({
setWorkspaceAttachments, setWorkspaceAttachments,
workspaceAttachmentScopeKey, workspaceAttachmentScopeKey,
]); ]);
const { const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
status: prStatus,
githubFeaturesEnabled,
payloadError: prPayloadError,
} = useCheckoutPrStatusQuery({
serverId, serverId,
cwd, cwd,
enabled: isGit, enabled: isGit,
@@ -2079,45 +1672,6 @@ export function GitDiffPane({
const diffBodyLineHeight = theme.lineHeight.diff; const diffBodyLineHeight = theme.lineHeight.diff;
const diffBodyChromeHeight = theme.borderWidth[1] * 2; const diffBodyChromeHeight = theme.borderWidth[1] * 2;
const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight; 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 { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = []; const items: DiffFlatItem[] = [];
const stickyIndices: number[] = []; const stickyIndices: number[] = [];
@@ -2304,119 +1858,6 @@ export function GitDiffPane({
setDiffModeOverride(null); setDiffModeOverride(null);
}, [autoDiffMode]); }, [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<GitActionRunners>(
() => ({
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( const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => { ({ item }: { item: DiffFlatItem }) => {
if (item.type === "header") { if (item.type === "header") {
@@ -2499,59 +1940,30 @@ export function GitDiffPane({
const hasChanges = files.length > 0; const hasChanges = files.length > 0;
const diffErrorMessage = diffPayloadError?.message ?? null; const diffErrorMessage = diffPayloadError?.message ?? null;
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError); 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 baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]);
const iconColor = theme.colors.foregroundMuted;
const gitActionsIcons = useMemo(
() => ({
commit: <GitCommitHorizontal size={16} color={iconColor} />,
pull: <Download size={16} color={iconColor} />,
push: <Upload size={16} color={iconColor} />,
pullAndPush: <ArrowDownUp size={16} color={iconColor} />,
viewPr: <GitHubIcon size={16} color={iconColor} />,
createPr: <GitHubIcon size={16} color={iconColor} />,
mergePrSquash: <GitHubIcon size={16} color={iconColor} />,
mergePrMerge: <GitHubIcon size={16} color={iconColor} />,
mergePrRebase: <GitHubIcon size={16} color={iconColor} />,
merge: <GitMerge size={16} color={iconColor} />,
mergeFromBase: <RefreshCcw size={16} color={iconColor} />,
archive: <Archive size={16} color={iconColor} />,
}),
[iconColor],
);
const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons });
const committedDiffDescription = useMemo( const committedDiffDescription = useMemo(
() => computeCommittedDiffDescription(branchLabel, baseRefLabel), () => computeCommittedDiffDescription(branchLabel, baseRefLabel),
[baseRefLabel, branchLabel], [baseRefLabel, branchLabel],
); );
const isOnBaseBranch = currentBranch === baseRefLabel;
const shouldPromoteArchive = computeShouldPromoteArchive(
isPaseoOwnedWorktree,
hasUncommittedChanges,
postShipArchiveSuggested,
isMergedPullRequest,
);
const gitActionsStatuses = useMemo<GitActionsStatusInputs>(
() => ({
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
}),
[
archiveStatus,
commitStatus,
mergeFromBaseStatus,
mergeStatus,
prCreateStatus,
pullAndPushStatus,
pullStatus,
pushStatus,
],
);
const gitActionsDisabled = useMemo<GitActionsDisabledInputs>(
() => computeDisabledStates(actionsDisabled, gitActionsStatuses),
[actionsDisabled, gitActionsStatuses],
);
const emptyMessage = computeEmptyMessage( const emptyMessage = computeEmptyMessage(
changesPreferences.hideWhitespace, 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 ( return (
<View style={styles.container}> <View style={styles.container}>
{!hideHeaderRow ? ( {!hideHeaderRow ? (
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { shouldAnchorHeaderBeforeCollapse } from "./git-diff-scroll"; import { shouldAnchorHeaderBeforeCollapse } from "./diff-scroll";
describe("shouldAnchorHeaderBeforeCollapse", () => { describe("shouldAnchorHeaderBeforeCollapse", () => {
it("skips anchor when header is fully visible in viewport", () => { it("skips anchor when header is fully visible in viewport", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-repo-url"; import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url";
describe("parseGitHubRepoFromRemote", () => { describe("parseGitHubRepoFromRemote", () => {
it.each([ it.each([
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; 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> = {}): BuildGitActionsInput { function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return { return {
@@ -8,6 +8,10 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
githubFeaturesEnabled: true, githubFeaturesEnabled: true,
hasPullRequest: false, hasPullRequest: false,
pullRequestUrl: null, pullRequestUrl: null,
pullRequestState: null,
pullRequestIsDraft: false,
pullRequestIsMerged: false,
pullRequestMergeable: "UNKNOWN",
hasRemote: false, hasRemote: false,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
isOnBaseBranch: true, isOnBaseBranch: true,
@@ -46,6 +50,21 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle", status: "idle",
handler: () => undefined, 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": { "merge-branch": {
disabled: false, disabled: false,
status: "idle", status: "idle",
@@ -158,6 +177,9 @@ describe("git-actions-policy", () => {
"merge-from-base", "merge-from-base",
"merge-branch", "merge-branch",
"pr", "pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
]); ]);
expect( expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"), 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(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true); 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",
]);
});
}); });
@@ -1,6 +1,7 @@
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu"; import type { ActionStatus } from "@/components/ui/dropdown-menu";
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@server/shared/messages";
export type GitActionId = export type GitActionId =
| "commit" | "commit"
@@ -8,6 +9,9 @@ export type GitActionId =
| "push" | "push"
| "pull-and-push" | "pull-and-push"
| "pr" | "pr"
| "merge-pr-squash"
| "merge-pr-merge"
| "merge-pr-rebase"
| "merge-branch" | "merge-branch"
| "merge-from-base" | "merge-from-base"
| "archive-worktree"; | "archive-worktree";
@@ -21,6 +25,8 @@ export interface GitAction {
status: ActionStatus; status: ActionStatus;
unavailableMessage?: string; unavailableMessage?: string;
icon?: ReactElement; icon?: ReactElement;
/** When true, a menu separator should be rendered before this item. */
startsGroup: boolean;
handler: () => void; handler: () => void;
} }
@@ -42,6 +48,10 @@ export interface BuildGitActionsInput {
githubFeaturesEnabled: boolean; githubFeaturesEnabled: boolean;
hasPullRequest: boolean; hasPullRequest: boolean;
pullRequestUrl: string | null; pullRequestUrl: string | null;
pullRequestState: "open" | "closed" | null;
pullRequestIsDraft: boolean;
pullRequestIsMerged: boolean;
pullRequestMergeable: PullRequestMergeable;
hasRemote: boolean; hasRemote: boolean;
isPaseoOwnedWorktree: boolean; isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean; isOnBaseBranch: boolean;
@@ -58,7 +68,29 @@ export interface BuildGitActionsInput {
} }
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"]; 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 { export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) { if (!input.isGit) {
@@ -75,6 +107,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
disabled: input.runtime.commit.disabled, disabled: input.runtime.commit.disabled,
status: input.runtime.commit.status, status: input.runtime.commit.status,
icon: input.runtime.commit.icon, icon: input.runtime.commit.icon,
startsGroup: false,
handler: input.runtime.commit.handler, handler: input.runtime.commit.handler,
}); });
@@ -87,6 +120,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
status: input.runtime.pull.status, status: input.runtime.pull.status,
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input), unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
icon: input.runtime.pull.icon, icon: input.runtime.pull.icon,
startsGroup: false,
handler: input.runtime.pull.handler, handler: input.runtime.pull.handler,
}); });
@@ -99,6 +133,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
status: input.runtime.push.status, status: input.runtime.push.status,
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input), unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
icon: input.runtime.push.icon, icon: input.runtime.push.icon,
startsGroup: false,
handler: input.runtime.push.handler, handler: input.runtime.push.handler,
}); });
@@ -113,14 +148,19 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined ? undefined
: getPullAndPushUnavailableMessage(input), : getPullAndPushUnavailableMessage(input),
icon: input.runtime["pull-and-push"].icon, icon: input.runtime["pull-and-push"].icon,
startsGroup: false,
handler: input.runtime["pull-and-push"].handler, handler: input.runtime["pull-and-push"].handler,
}); });
allActions.set("pr", buildPrAction(input)); 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", { allActions.set("merge-branch", {
id: "merge-branch", id: "merge-branch",
label: `Merge into ${input.baseRefLabel}`, label: "Merge locally",
pendingLabel: "Merging...", pendingLabel: "Merging...",
successLabel: "Merged", successLabel: "Merged",
disabled: input.runtime["merge-branch"].disabled, disabled: input.runtime["merge-branch"].disabled,
@@ -129,6 +169,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined ? undefined
: getMergeBranchUnavailableMessage(input), : getMergeBranchUnavailableMessage(input),
icon: input.runtime["merge-branch"].icon, icon: input.runtime["merge-branch"].icon,
startsGroup: false,
handler: input.runtime["merge-branch"].handler, handler: input.runtime["merge-branch"].handler,
}); });
@@ -143,6 +184,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined ? undefined
: getMergeFromBaseUnavailableMessage(input), : getMergeFromBaseUnavailableMessage(input),
icon: input.runtime["merge-from-base"].icon, icon: input.runtime["merge-from-base"].icon,
startsGroup: true,
handler: input.runtime["merge-from-base"].handler, handler: input.runtime["merge-from-base"].handler,
}); });
@@ -158,6 +200,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined ? undefined
: "Archive isn't available here because this workspace was not created as a Paseo worktree", : "Archive isn't available here because this workspace was not created as a Paseo worktree",
icon: input.runtime["archive-worktree"].icon, icon: input.runtime["archive-worktree"].icon,
startsGroup: true,
handler: input.runtime["archive-worktree"].handler, handler: input.runtime["archive-worktree"].handler,
}); });
@@ -195,11 +238,17 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (!input.isOnBaseBranch && canMergeFromBase(input)) { if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base"; 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) { if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr"; return "pr";
} }
if (!input.isOnBaseBranch && input.aheadCount > 0) { if (!input.isOnBaseBranch && input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr"; return "pr";
} }
return null; return null;
} }
@@ -218,6 +267,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
? undefined ? undefined
: "View PR isn't available right now because GitHub isn't connected", : "View PR isn't available right now because GitHub isn't connected",
icon: input.runtime.pr.icon, icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler, handler: input.runtime.pr.handler,
}; };
} }
@@ -233,10 +283,32 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
? undefined ? undefined
: getCreatePrUnavailableMessage(input), : getCreatePrUnavailableMessage(input),
icon: input.runtime.pr.icon, icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler, 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 { function canPull(input: BuildGitActionsInput): boolean {
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0; 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 { function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) { if (!input.hasRemote) {
return "Pull isn't available here because this branch is not connected to a remote yet"; 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; 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"
);
}
@@ -23,6 +23,7 @@ const baseStatus: CheckoutPrStatus = {
headRefName: "feature/pr-pane", headRefName: "feature/pr-pane",
isMerged: false, isMerged: false,
isDraft: false, isDraft: false,
mergeable: "UNKNOWN",
checks: [], checks: [],
reviewDecision: null, reviewDecision: null,
}; };
@@ -16,14 +16,14 @@ import {
MessageSquare, MessageSquare,
} from "lucide-react-native"; } from "lucide-react-native";
import { openExternalUrl } from "@/utils/open-external-url"; 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 { import type {
CheckStatus, CheckStatus,
PrPaneActivity, PrPaneActivity,
PrPaneCheck, PrPaneCheck,
PrPaneData, PrPaneData,
PrState, PrState,
} from "@/utils/pr-pane-data"; } from "@/git/pr-pane-data";
function rowPressableStyle({ hovered }: { hovered?: boolean }) { function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.row, Boolean(hovered) && styles.hoverable]; return [styles.row, Boolean(hovered) && styles.hoverable];
@@ -1,24 +1,16 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react"; import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
type CheckoutGitActionStatus, import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
useCheckoutGitActionsStore, import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
} from "@/stores/checkout-git-actions-store"; import { buildGitActions, narrowPullRequestState, type GitActions } from "@/git/policy";
import { import type { CheckoutPrMergeMethod } from "@server/shared/messages";
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 { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; 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 { function openURLInNewTab(url: string): void {
void openExternalUrl(url); void openExternalUrl(url);
@@ -47,55 +39,6 @@ function formatBaseRefLabel(baseRef: string | undefined): string {
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; 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<CheckoutPrStatusPayload["status"]> | null; type PrStatusValue = NonNullable<CheckoutPrStatusPayload["status"]> | null;
interface DeriveGitActionsStateArgs { 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 { interface UseGitActionsInput {
serverId: string; serverId: string;
cwd: string; cwd: string;
@@ -212,6 +134,9 @@ interface UseGitActionsInput {
pullAndPush: ReactElement; pullAndPush: ReactElement;
viewPr: ReactElement; viewPr: ReactElement;
createPr: ReactElement; createPr: ReactElement;
mergePrSquash: ReactElement;
mergePrMerge: ReactElement;
mergePrRebase: ReactElement;
merge: ReactElement; merge: ReactElement;
mergeFromBase: ReactElement; mergeFromBase: ReactElement;
archive: ReactElement; archive: ReactElement;
@@ -287,27 +212,51 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
setPostShipArchiveSuggested(false); setPostShipArchiveSuggested(false);
}, [cwd]); }, [cwd]);
const { const commitStatus = useCheckoutGitActionsStore((s) =>
commitStatus, s.getStatus({ serverId, cwd, actionId: "commit" }),
pullStatus, );
pushStatus, const pullStatus = useCheckoutGitActionsStore((s) =>
pullAndPushStatus, s.getStatus({ serverId, cwd, actionId: "pull" }),
prCreateStatus, );
mergeStatus, const pushStatus = useCheckoutGitActionsStore((s) =>
mergeFromBaseStatus, s.getStatus({ serverId, cwd, actionId: "push" }),
archiveStatus, );
} = useGitActionStatuses(serverId, cwd); 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<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
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 { const runCommit = useCheckoutGitActionsStore((s) => s.commit);
runCommit, const runPull = useCheckoutGitActionsStore((s) => s.pull);
runPull, const runPush = useCheckoutGitActionsStore((s) => s.push);
runPush, const runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush);
runPullAndPush, const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr);
runCreatePr, const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr);
runMergeBranch, const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
runMergeFromBase, const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
runArchiveWorktree, const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
} = useGitActionRunners();
const toastActionError = useCallback( const toastActionError = useCallback(
(error: unknown, fallback: string) => { (error: unknown, fallback: string) => {
@@ -381,6 +330,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}); });
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]); }, [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(() => { const handleMergeBranch = useCallback(() => {
if (!baseRef) { if (!baseRef) {
toast.error("Base ref unavailable"); toast.error("Base ref unavailable");
@@ -463,15 +428,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shouldPromoteArchive, shouldPromoteArchive,
} = derived; } = 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({ const branchLabel = resolveBranchLabel({
currentBranch: gitStatus?.currentBranch, currentBranch: gitStatus?.currentBranch,
notGit, notGit,
@@ -492,6 +448,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
githubFeaturesEnabled, githubFeaturesEnabled,
hasPullRequest, hasPullRequest,
pullRequestUrl: prStatus?.url ?? null, pullRequestUrl: prStatus?.url ?? null,
pullRequestState: narrowPullRequestState(prStatus?.state),
pullRequestIsDraft: prStatus?.isDraft ?? false,
pullRequestIsMerged: prStatus?.isMerged ?? false,
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
hasRemote, hasRemote,
isPaseoOwnedWorktree, isPaseoOwnedWorktree,
isOnBaseBranch, isOnBaseBranch,
@@ -506,49 +466,67 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shipDefault, shipDefault,
runtime: { runtime: {
commit: { commit: {
disabled: commitDisabled, disabled: isActionDisabled(actionsDisabled, commitStatus),
status: commitStatus, status: commitStatus,
icon: icons.commit, icon: icons.commit,
handler: handleCommit, handler: handleCommit,
}, },
pull: { pull: {
disabled: pullDisabled, disabled: isActionDisabled(actionsDisabled, pullStatus),
status: pullStatus, status: pullStatus,
icon: icons.pull, icon: icons.pull,
handler: handlePull, handler: handlePull,
}, },
push: { push: {
disabled: pushDisabled, disabled: isActionDisabled(actionsDisabled, pushStatus),
status: pushStatus, status: pushStatus,
icon: icons.push, icon: icons.push,
handler: handlePush, handler: handlePush,
}, },
"pull-and-push": { "pull-and-push": {
disabled: pullAndPushDisabled, disabled: isActionDisabled(actionsDisabled, pullAndPushStatus),
status: pullAndPushStatus, status: pullAndPushStatus,
icon: icons.pullAndPush, icon: icons.pullAndPush,
handler: handlePullAndPush, handler: handlePullAndPush,
}, },
pr: { pr: {
disabled: prDisabled, disabled: isActionDisabled(actionsDisabled, prCreateStatus),
status: hasPullRequest ? "idle" : prCreateStatus, status: hasPullRequest ? "idle" : prCreateStatus,
icon: hasPullRequest ? icons.viewPr : icons.createPr, icon: hasPullRequest ? icons.viewPr : icons.createPr,
handler: handlePrAction, 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": { "merge-branch": {
disabled: mergeDisabled, disabled: isActionDisabled(actionsDisabled, mergeStatus),
status: mergeStatus, status: mergeStatus,
icon: icons.merge, icon: icons.merge,
handler: handleMergeBranch, handler: handleMergeBranch,
}, },
"merge-from-base": { "merge-from-base": {
disabled: mergeFromBaseDisabled, disabled: isActionDisabled(actionsDisabled, mergeFromBaseStatus),
status: mergeFromBaseStatus, status: mergeFromBaseStatus,
icon: icons.mergeFromBase, icon: icons.mergeFromBase,
handler: handleMergeFromBase, handler: handleMergeFromBase,
}, },
"archive-worktree": { "archive-worktree": {
disabled: archiveDisabled, disabled: isActionDisabled(actionsDisabled, archiveStatus),
status: archiveStatus, status: archiveStatus,
icon: icons.archive, icon: icons.archive,
handler: handleArchiveWorktree, handler: handleArchiveWorktree,
@@ -560,6 +538,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasRemote, hasRemote,
hasPullRequest, hasPullRequest,
prStatus?.url, prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
aheadCount, aheadCount,
behindBaseCount, behindBaseCount,
isPaseoOwnedWorktree, isPaseoOwnedWorktree,
@@ -571,19 +553,15 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shipDefault, shipDefault,
baseRefLabel, baseRefLabel,
shouldPromoteArchive, shouldPromoteArchive,
commitDisabled, actionsDisabled,
pullDisabled,
pushDisabled,
pullAndPushDisabled,
prDisabled,
mergeDisabled,
mergeFromBaseDisabled,
archiveDisabled,
commitStatus, commitStatus,
pullStatus, pullStatus,
pushStatus, pushStatus,
pullAndPushStatus, pullAndPushStatus,
prCreateStatus, prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
mergeStatus, mergeStatus,
mergeFromBaseStatus, mergeFromBaseStatus,
archiveStatus, archiveStatus,
@@ -592,6 +570,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handlePush, handlePush,
handlePullAndPush, handlePullAndPush,
handlePrAction, handlePrAction,
handleMergePr,
handleMergeBranch, handleMergeBranch,
handleMergeFromBase, handleMergeFromBase,
handleArchiveWorktree, handleArchiveWorktree,
@@ -2,7 +2,7 @@ import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useMemo } from "react"; import { useEffect, useId, useMemo } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order"; import { orderCheckoutDiffFiles } from "@/git/diff-order";
function checkoutDiffQueryKey( function checkoutDiffQueryKey(
serverId: string, serverId: string,
@@ -9,7 +9,7 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusResponse } from "@server/shared/messages"; 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"]; type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
@@ -9,8 +9,8 @@ import {
Upload, Upload,
} from "lucide-react-native"; } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon"; import { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/hooks/use-git-actions"; import { useGitActions } from "@/git/use-actions";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
interface WorkspaceGitActionsProps { interface WorkspaceGitActionsProps {
@@ -39,6 +39,9 @@ const ICONS = {
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />, pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />, viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />, createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />, merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />, mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />, archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
@@ -3,7 +3,7 @@ import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox"; import type { ComboboxOption } from "@/components/ui/combobox";
import type { ToastApi } from "@/components/toast-host"; 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"; import { confirmDialog } from "@/utils/confirm-dialog";
interface UseBranchSwitcherInput { interface UseBranchSwitcherInput {
@@ -17,7 +17,7 @@ import {
usePrPaneData, usePrPaneData,
type UsePrPaneDataResult, type UsePrPaneDataResult,
} from "./use-pr-pane-data"; } from "./use-pr-pane-data";
import { useWorkspacePrHint } from "./use-checkout-pr-status-query"; import { useWorkspacePrHint } from "@/git/use-pr-status-query";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>; type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
@@ -67,6 +67,7 @@ function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
headRefName: "feature/pr-pane", headRefName: "feature/pr-pane",
isMerged: false, isMerged: false,
isDraft: false, isDraft: false,
mergeable: "UNKNOWN",
checks: [], checks: [],
reviewDecision: null, reviewDecision: null,
repoOwner: "getpaseo", repoOwner: "getpaseo",
+2 -2
View File
@@ -4,8 +4,8 @@ import type {
CheckoutPrStatusResponse, CheckoutPrStatusResponse,
PullRequestTimelineResponse, PullRequestTimelineResponse,
} from "@server/shared/messages"; } from "@server/shared/messages";
import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data"; import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"]; type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
type PullRequestTimeline = PullRequestTimelineResponse["payload"]; type PullRequestTimeline = PullRequestTimelineResponse["payload"];
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/test/window-local-storage"; import "@/test/window-local-storage";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query"; import type { ParsedDiffFile } from "@/git/use-diff-query";
import { import {
buildReviewAttachmentSnapshot, buildReviewAttachmentSnapshot,
buildReviewDraftKey, buildReviewDraftKey,
+1 -1
View File
@@ -3,7 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from "zustand/middleware";
import type { ComposerAttachment } from "@/attachments/types"; 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 { generateMessageId } from "@/types/stream";
import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout"; import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout";
import type { AgentAttachment } from "@server/shared/messages"; import type { AgentAttachment } from "@server/shared/messages";
@@ -19,11 +19,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context"; 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 { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; 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 { openExternalUrl } from "@/utils/open-external-url";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -54,7 +54,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container"; import { SplitContainer } from "@/components/split-container";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; 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 { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; 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 { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import { import { checkoutStatusQueryKey, type CheckoutStatusPayload } from "@/git/use-status-query";
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import type { ListTerminalsResponse } from "@server/shared/messages"; import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
+1 -1
View File
@@ -4,7 +4,7 @@ import {
buildSplitDiffRows, buildSplitDiffRows,
buildUnifiedDiffLines, buildUnifiedDiffLines,
} from "./diff-layout"; } 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"; import type { ReviewableDiffTarget } from "./diff-layout";
function makeFile( function makeFile(
+1 -1
View File
@@ -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 ReviewSide = "old" | "new";
type ReviewableLineType = "add" | "remove" | "context"; type ReviewableLineType = "add" | "remove" | "context";
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { import {
parseAgentKey, parseAgentKey,
resolveNewAgentWorkingDir, resolveNewAgentWorkingDir,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { import {
parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceOpenIntentFromPathname,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
@@ -30,6 +30,8 @@ import type {
CheckoutPullResponse, CheckoutPullResponse,
CheckoutPushResponse, CheckoutPushResponse,
CheckoutPrCreateResponse, CheckoutPrCreateResponse,
CheckoutPrMergeResponse,
CheckoutPrMergeMethod,
CheckoutPrStatusResponse, CheckoutPrStatusResponse,
PullRequestTimelineResponse, PullRequestTimelineResponse,
CheckoutSwitchBranchResponse, CheckoutSwitchBranchResponse,
@@ -263,6 +265,7 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPullPayload = CheckoutPullResponse["payload"]; type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"]; type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
@@ -2744,6 +2747,23 @@ export class DaemonClient {
}); });
} }
async checkoutPrMerge(
cwd: string,
input: { method: CheckoutPrMergeMethod },
requestId?: string,
): Promise<CheckoutPrMergePayload> {
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<CheckoutPrStatusPayload> { async checkoutPrStatus(cwd: string, requestId?: string): Promise<CheckoutPrStatusPayload> {
return this.sendCorrelatedSessionRequest({ return this.sendCorrelatedSessionRequest({
requestId, requestId,
@@ -282,6 +282,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1, number: 1,
url: "https://github.com/acme/repo/pull/1", url: "https://github.com/acme/repo/pull/1",
}), }),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true, isAuthenticated: async () => true,
invalidate: () => {}, invalidate: () => {},
}; };
@@ -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<typeof GhPrViewSchema>;
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<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function pollForMergeable(
ctx: DaemonTestContext,
worktreePath: string,
): Promise<number | undefined> {
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,
);
});
@@ -4,7 +4,11 @@ import { tmpdir } from "os";
import path from "path"; import path from "path";
import { execSync } from "child_process"; 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 { import {
createWorktree as createWorktreePrimitive, createWorktree as createWorktreePrimitive,
type CreateWorktreeOptions, 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 { function getGhLogin(): string {
return execSync("gh api user --jq .login", { stdio: "pipe" }).toString().trim(); return execSync("gh api user --jq .login", { stdio: "pipe" }).toString().trim();
} }
@@ -128,7 +127,7 @@ describe("daemon checkout ship loop", () => {
initGitRepo(repoDir); initGitRepo(repoDir);
const owner = getGhLogin(); const owner = getGhLogin();
const repoName = createTempRepoName(); const repoName = createTempGithubRepoName("checkout-ship");
repoFullName = `${owner}/${repoName}`; repoFullName = `${owner}/${repoName}`;
createPrivateRepo(repoName); createPrivateRepo(repoName);
@@ -498,6 +498,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1, number: 1,
url: "https://github.com/acme/repo/pull/1", url: "https://github.com/acme/repo/pull/1",
}), }),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true, isAuthenticated: async () => true,
invalidate: () => {}, invalidate: () => {},
}; };
@@ -42,6 +42,7 @@ function createResolverHarness(): ResolverHarness {
number: 1, number: 1,
url: "https://github.com/acme/repo/pull/1", url: "https://github.com/acme/repo/pull/1",
}), }),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true, isAuthenticated: async () => true,
invalidate: () => {}, invalidate: () => {},
}; };
@@ -65,6 +65,7 @@ interface SessionHandlerInternals {
handleCheckoutMergeFromBaseRequest(params: unknown): Promise<unknown>; handleCheckoutMergeFromBaseRequest(params: unknown): Promise<unknown>;
handleCheckoutCommitRequest(params: unknown): Promise<unknown>; handleCheckoutCommitRequest(params: unknown): Promise<unknown>;
handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>; handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>;
handleCheckoutPrMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPullRequest(params: unknown): Promise<unknown>; handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>; handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutStatusRequest(params: unknown): Promise<unknown>; handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
@@ -288,6 +289,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
invalidate: vi.fn(), invalidate: vi.fn(),
searchIssuesAndPrs: vi.fn(), searchIssuesAndPrs: vi.fn(),
createPullRequest: vi.fn(), createPullRequest: vi.fn(),
mergePullRequest: vi.fn(),
}; };
const checkoutDiffManager = options.checkoutDiffManager ?? { const checkoutDiffManager = options.checkoutDiffManager ?? {
scheduleRefreshForCwd: vi.fn(), scheduleRefreshForCwd: vi.fn(),
@@ -1022,6 +1024,7 @@ describe("session PR status payload normalization", () => {
headRefName: "feature/pr-pane", headRefName: "feature/pr-pane",
isMerged: false, isMerged: false,
isDraft: true, isDraft: true,
mergeable: "MERGEABLE",
checks: [ checks: [
{ {
name: "typecheck", name: "typecheck",
@@ -1037,6 +1040,7 @@ describe("session PR status payload normalization", () => {
expect(payload).toHaveProperty("repoOwner", "internal-owner"); expect(payload).toHaveProperty("repoOwner", "internal-owner");
expect(payload).toHaveProperty("repoName", "internal-repo"); expect(payload).toHaveProperty("repoName", "internal-repo");
expect(payload).toHaveProperty("mergeable", "MERGEABLE");
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload); 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", () => { describe("session checkout pull and push handling", () => {
test("forces workspace git and GitHub refresh after pulling", async () => { test("forces workspace git and GitHub refresh after pulling", async () => {
const messages: unknown[] = []; const messages: unknown[] = [];
+46
View File
@@ -270,6 +270,7 @@ type GitMutationRefreshReason =
| "push" | "push"
| "merge-to-base" | "merge-to-base"
| "merge-from-base" | "merge-from-base"
| "merge-pr"
| "create-pr" | "create-pr"
| "switch-branch" | "switch-branch"
| "create-branch" | "create-branch"
@@ -2011,6 +2012,7 @@ export class Session {
}); });
} }
// eslint-disable-next-line complexity
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined { private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) { switch (msg.type) {
case "checkout_status_request": case "checkout_status_request":
@@ -2046,6 +2048,8 @@ export class Session {
return this.handleCheckoutPushRequest(msg); return this.handleCheckoutPushRequest(msg);
case "checkout_pr_create_request": case "checkout_pr_create_request":
return this.handleCheckoutPrCreateRequest(msg); return this.handleCheckoutPrCreateRequest(msg);
case "checkout_pr_merge_request":
return this.handleCheckoutPrMergeRequest(msg);
case "checkout_pr_status_request": case "checkout_pr_status_request":
return this.handleCheckoutPrStatusRequest(msg); return this.handleCheckoutPrStatusRequest(msg);
case "pull_request_timeline_request": case "pull_request_timeline_request":
@@ -5282,6 +5286,47 @@ export class Session {
} }
} }
private async handleCheckoutPrMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_merge_request" }>,
): Promise<void> {
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( private async handleCheckoutPrStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>, msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>,
): Promise<void> { ): Promise<void> {
@@ -8755,6 +8800,7 @@ export function normalizeCheckoutPrStatusPayload(
headRefName: status.headRefName, headRefName: status.headRefName,
isMerged: status.isMerged, isMerged: status.isMerged,
isDraft: status.isDraft ?? false, isDraft: status.isDraft ?? false,
mergeable: status.mergeable ?? "UNKNOWN",
checks: status.checks ?? [], checks: status.checks ?? [],
checksStatus: status.checksStatus, checksStatus: status.checksStatus,
reviewDecision: status.reviewDecision, reviewDecision: status.reviewDecision,
@@ -472,6 +472,7 @@ describe("workspace git watch targets", () => {
baseRefName: "main", baseRefName: "main",
headRefName: "workspace-git-service", headRefName: "workspace-git-service",
isMerged: false, isMerged: false,
mergeable: "UNKNOWN",
isDraft: false, isDraft: false,
checks: [ checks: [
{ {
@@ -531,6 +532,7 @@ describe("workspace git watch targets", () => {
baseRefName: "main", baseRefName: "main",
headRefName: "workspace-git-service", headRefName: "workspace-git-service",
isMerged: true, isMerged: true,
mergeable: "UNKNOWN",
isDraft: false, isDraft: false,
checks: [], checks: [],
checksStatus: undefined, checksStatus: undefined,
@@ -9,3 +9,4 @@ export {
} from "./daemon-client.js"; } from "./daemon-client.js";
export { createDaemonTestContext, type DaemonTestContext } from "./daemon-test-context.js"; export { createDaemonTestContext, type DaemonTestContext } from "./daemon-test-context.js";
export { useTempClaudeConfigDir } from "./claude-config.js"; export { useTempClaudeConfigDir } from "./claude-config.js";
export { TEMP_GITHUB_REPO_PREFIX, createTempGithubRepoName } from "./temp-github-repo.js";
@@ -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}`;
}
@@ -193,6 +193,7 @@ function createGitHubServiceStub(): GitHubService {
url: "https://github.com/acme/repo/pull/1", url: "https://github.com/acme/repo/pull/1",
number: 1, number: 1,
})), })),
mergePullRequest: vi.fn(async () => ({ success: true })),
isAuthenticated: vi.fn(async () => true), isAuthenticated: vi.fn(async () => true),
invalidate: vi.fn(), invalidate: vi.fn(),
}; };
@@ -176,6 +176,7 @@ function createGitHubServiceStub(): GitHubService {
url: "https://github.com/acme/repo/pull/1", url: "https://github.com/acme/repo/pull/1",
number: 1, number: 1,
})), })),
mergePullRequest: vi.fn(async () => ({ success: true })),
isAuthenticated: vi.fn(async () => true), isAuthenticated: vi.fn(async () => true),
invalidate: vi.fn(), invalidate: vi.fn(),
}; };
@@ -20,7 +20,11 @@ import {
resolveBranchCheckout, resolveBranchCheckout,
resolveAbsoluteGitDir, resolveAbsoluteGitDir,
} from "../utils/checkout-git.js"; } 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 { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import { runGitCommand } from "../utils/run-git-command.js"; import { runGitCommand } from "../utils/run-git-command.js";
import { resolveGitHubRemote, type GitHubRemoteIdentity } from "../utils/github-remote.js"; import { resolveGitHubRemote, type GitHubRemoteIdentity } from "../utils/github-remote.js";
@@ -78,6 +82,7 @@ export interface WorkspaceGitRuntimeSnapshot {
headRefName: string; headRefName: string;
isMerged: boolean; isMerged: boolean;
isDraft?: boolean; isDraft?: boolean;
mergeable?: PullRequestMergeable;
checks?: Array<{ checks?: Array<{
name: string; name: string;
status: "success" | "failure" | "pending" | "skipped" | "cancelled"; status: "success" | "failure" | "pending" | "skipped" | "cancelled";
@@ -39,6 +39,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1, number: 1,
url: "https://github.com/acme/repo/pull/1", url: "https://github.com/acme/repo/pull/1",
}), }),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true, isAuthenticated: async () => true,
invalidate: () => {}, invalidate: () => {},
}; };
@@ -149,6 +149,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1, number: 1,
url: "https://github.com/acme/repo/pull/1", url: "https://github.com/acme/repo/pull/1",
}), }),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true, isAuthenticated: async () => true,
invalidate: () => {}, invalidate: () => {},
}; };
@@ -24,6 +24,7 @@ const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
interface RunnerCall { interface RunnerCall {
args: string[]; args: string[];
cwd: string; cwd: string;
envOverlay?: Record<string, string>;
} }
interface TestRunner { interface TestRunner {
@@ -46,7 +47,7 @@ function createRunner(stdoutByCall: string[]): TestRunner {
return { return {
calls, calls,
runner: async (args: string[], options: GitHubCommandRunnerOptions) => { 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() ?? "[]"; const stdout = stdoutByCall.shift() ?? "[]";
return { stdout, stderr: "" }; return { stdout, stderr: "" };
}, },
@@ -60,7 +61,7 @@ function createScriptedRunner(steps: RunnerStep[]): TestRunner {
return { return {
calls, calls,
runner: async (args: string[], options: GitHubCommandRunnerOptions) => { 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() ?? ""; const step = steps.shift() ?? "";
if (typeof step === "string") { if (typeof step === "string") {
return { stdout: step, stderr: "" }; return { stdout: step, stderr: "" };
@@ -81,7 +82,7 @@ function createDeferredRunner(): TestRunner {
return { return {
calls, calls,
runner: (args: string[], options: GitHubCommandRunnerOptions) => { runner: (args: string[], options: GitHubCommandRunnerOptions) => {
calls.push({ args, cwd: options.cwd }); calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
return new Promise((resolve) => { return new Promise((resolve) => {
resolveNext = (stdout: string) => resolve({ stdout, stderr: "" }); resolveNext = (stdout: string) => resolve({ stdout, stderr: "" });
}); });
@@ -125,6 +126,7 @@ function createCurrentPullRequestStatus(
headRefName: "feature", headRefName: "feature",
isMerged: false, isMerged: false,
isDraft: false, isDraft: false,
mergeable: "MERGEABLE",
checks: [], checks: [],
checksStatus: "none", checksStatus: "none",
reviewDecision: null, reviewDecision: null,
@@ -284,6 +286,33 @@ describe("GitHubService", () => {
vi.useRealTimers(); 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", () => { it("computes fast cadence for pending and slow cadence for stable PR states", () => {
const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" }); const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" });
const runningCheckStatus = createCurrentPullRequestStatus({ const runningCheckStatus = createCurrentPullRequestStatus({
@@ -1048,7 +1077,7 @@ describe("GitHubService", () => {
"pr", "pr",
"view", "view",
"--json", "--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({ expect(status).toEqual({
number: 42, number: 42,
@@ -1061,6 +1090,7 @@ describe("GitHubService", () => {
headRefName: "feature/pr-pane", headRefName: "feature/pr-pane",
isMerged: false, isMerged: false,
isDraft: true, isDraft: true,
mergeable: "UNKNOWN",
checks: [ checks: [
{ {
name: "server-tests", 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 () => { it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([ const runner = createScriptedRunner([
currentPullRequestJson({ currentPullRequestJson({
@@ -1145,7 +1194,7 @@ describe("GitHubService", () => {
"pr", "pr",
"view", "view",
"--json", "--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"], ["repo", "view", "--json", "owner,name,parent"],
[ [
@@ -1158,7 +1207,7 @@ describe("GitHubService", () => {
"--head", "--head",
"forkOwner:feature/fork", "forkOwner:feature/fork",
"--json", "--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", "--limit",
"10", "10",
], ],
@@ -1218,7 +1267,7 @@ describe("GitHubService", () => {
"pr", "pr",
"view", "view",
"--json", "--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"], ["repo", "view", "--json", "owner,name,parent"],
]); ]);
@@ -1279,7 +1328,7 @@ describe("GitHubService", () => {
"pr", "pr",
"view", "view",
"--json", "--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", "pr",
@@ -1289,7 +1338,7 @@ describe("GitHubService", () => {
"--head", "--head",
"main", "main",
"--json", "--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", "--limit",
"10", "10",
], ],
@@ -1452,6 +1501,7 @@ describe("GitHubService", () => {
headRefName: "feature", headRefName: "feature",
isMerged: false, isMerged: false,
isDraft: true, isDraft: true,
mergeable: "UNKNOWN",
checks: [ checks: [
{ {
name: "server-tests", name: "server-tests",
+30 -2
View File
@@ -91,6 +91,8 @@ const HeadRepositoryOwnerSchema = z
.nullable() .nullable()
.optional(); .optional();
const PullRequestMergeableSchema = z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN");
const CurrentPullRequestStatusSchema = z.object({ const CurrentPullRequestStatusSchema = z.object({
number: z.number().optional(), number: z.number().optional(),
url: z.string().catch(""), url: z.string().catch(""),
@@ -102,6 +104,7 @@ const CurrentPullRequestStatusSchema = z.object({
mergedAt: z.string().nullable().optional(), mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(), statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(), reviewDecision: z.unknown().optional(),
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
headRepositoryOwner: HeadRepositoryOwnerSchema, headRepositoryOwner: HeadRepositoryOwnerSchema,
}); });
@@ -234,7 +237,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
}`; }`;
const CURRENT_PR_STATUS_FIELDS = 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 = ` const PULL_REQUEST_TIMELINE_QUERY = `
query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) { query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
@@ -290,6 +293,7 @@ interface GitHubServiceDependencies {
export interface GitHubCommandRunnerOptions { export interface GitHubCommandRunnerOptions {
cwd: string; cwd: string;
envOverlay?: Record<string, string>;
} }
export interface GitHubCommandResult { export interface GitHubCommandResult {
@@ -346,6 +350,7 @@ export interface PullRequestCheck {
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure"; export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null; export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
export interface GitHubCurrentPullRequestStatus { export interface GitHubCurrentPullRequestStatus {
number?: number; number?: number;
@@ -358,6 +363,7 @@ export interface GitHubCurrentPullRequestStatus {
headRefName: string; headRefName: string;
isMerged: boolean; isMerged: boolean;
isDraft?: boolean; isDraft?: boolean;
mergeable: PullRequestMergeable;
checks: PullRequestCheck[]; checks: PullRequestCheck[];
checksStatus: PullRequestChecksStatus; checksStatus: PullRequestChecksStatus;
reviewDecision: PullRequestReviewDecision; reviewDecision: PullRequestReviewDecision;
@@ -404,6 +410,18 @@ export interface GitHubPullRequestCreateResult {
number: number; 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 = export type GitHubReadOptions =
| { | {
force?: false; force?: false;
@@ -492,6 +510,7 @@ export interface GitHubService {
createPullRequest( createPullRequest(
options: CreateGitHubPullRequestOptions, options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>; ): Promise<GitHubPullRequestCreateResult>;
mergePullRequest(options: MergeGitHubPullRequestOptions): Promise<GitHubPullRequestMergeResult>;
isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>; isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>;
retainCurrentPullRequestStatusPoll?(options: { retainCurrentPullRequestStatusPoll?(options: {
cwd: string; cwd: string;
@@ -1030,6 +1049,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return parsed; 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) { isAuthenticated(input) {
return cached({ return cached({
cwd: input.cwd, cwd: input.cwd,
@@ -1167,7 +1194,7 @@ async function runGhCommand(
): Promise<GitHubCommandResult> { ): Promise<GitHubCommandResult> {
return execCommand("gh", args, { return execCommand("gh", args, {
cwd: options.cwd, cwd: options.cwd,
envOverlay: GITHUB_ENV, envOverlay: { ...GITHUB_ENV, ...options.envOverlay },
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
}); });
} }
@@ -1690,6 +1717,7 @@ function toCurrentPullRequestStatus(
headRefName: item.headRefName || fallbackHeadRefName, headRefName: item.headRefName || fallbackHeadRefName,
isMerged: mergedAt !== null, isMerged: mergedAt !== null,
isDraft: item.isDraft ?? false, isDraft: item.isDraft ?? false,
mergeable: item.mergeable,
checks, checks,
checksStatus: computeChecksStatus(checks), checksStatus: computeChecksStatus(checks),
reviewDecision: mapReviewDecision(item.reviewDecision), reviewDecision: mapReviewDecision(item.reviewDecision),
@@ -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();
});
});
+28
View File
@@ -1272,6 +1272,13 @@ export const CheckoutPrCreateRequestSchema = z.object({
requestId: z.string(), 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({ export const CheckoutPrStatusRequestSchema = z.object({
type: z.literal("checkout_pr_status_request"), type: z.literal("checkout_pr_status_request"),
cwd: z.string(), cwd: z.string(),
@@ -1719,6 +1726,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPullRequestSchema, CheckoutPullRequestSchema,
CheckoutPushRequestSchema, CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema, CheckoutPrCreateRequestSchema,
CheckoutPrMergeRequestSchema,
CheckoutPrStatusRequestSchema, CheckoutPrStatusRequestSchema,
PullRequestTimelineRequestSchema, PullRequestTimelineRequestSchema,
CheckoutSwitchBranchRequestSchema, CheckoutSwitchBranchRequestSchema,
@@ -2661,6 +2669,11 @@ export const CheckoutPrStatusSchema = z.object({
headRefName: z.string(), headRefName: z.string(),
isMerged: z.boolean(), isMerged: z.boolean(),
isDraft: z.boolean().optional().default(false), isDraft: z.boolean().optional().default(false),
mergeable: z
.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"])
.catch("UNKNOWN")
.optional()
.default("UNKNOWN"),
checks: z checks: z
.array( .array(
z.object({ 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({ export const CheckoutPrStatusResponseSchema = z.object({
type: z.literal("checkout_pr_status_response"), type: z.literal("checkout_pr_status_response"),
payload: CheckoutPrStatusPayloadSchema, payload: CheckoutPrStatusPayloadSchema,
@@ -3338,6 +3361,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPullResponseSchema, CheckoutPullResponseSchema,
CheckoutPushResponseSchema, CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema, CheckoutPrCreateResponseSchema,
CheckoutPrMergeResponseSchema,
CheckoutPrStatusResponseSchema, CheckoutPrStatusResponseSchema,
PullRequestTimelineResponseSchema, PullRequestTimelineResponseSchema,
CheckoutSwitchBranchResponseSchema, CheckoutSwitchBranchResponseSchema,
@@ -3585,6 +3609,10 @@ export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>; export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>; export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>; export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>;
export type CheckoutPrMergeResponse = z.infer<typeof CheckoutPrMergeResponseSchema>;
export type CheckoutPrMergeMethod = z.infer<typeof CheckoutPrMergeRequestSchema>["mergeMethod"];
export type PullRequestMergeable = z.infer<typeof CheckoutPrStatusSchema>["mergeable"];
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>; export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>; export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
export type PullRequestTimelineRequest = z.infer<typeof PullRequestTimelineRequestSchema>; export type PullRequestTimelineRequest = z.infer<typeof PullRequestTimelineRequestSchema>;
@@ -124,6 +124,7 @@ function createGitHubServiceForStatus(
url: "https://github.com/getpaseo/paseo/pull/1", url: "https://github.com/getpaseo/paseo/pull/1",
number: 1, number: 1,
}), }),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true, isAuthenticated: async () => true,
invalidate: () => {}, invalidate: () => {},
}; };
@@ -12,6 +12,7 @@ import {
createGitHubService, createGitHubService,
resolveGitHubRepo, resolveGitHubRepo,
type GitHubService, type GitHubService,
type PullRequestMergeable,
} from "../services/github-service.js"; } from "../services/github-service.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js"; import { runGitCommand } from "./run-git-command.js";
@@ -2309,6 +2310,7 @@ export interface PullRequestStatus {
headRefName: string; headRefName: string;
isMerged: boolean; isMerged: boolean;
isDraft?: boolean; isDraft?: boolean;
mergeable?: PullRequestMergeable;
checks?: PullRequestCheck[]; checks?: PullRequestCheck[];
checksStatus?: ChecksStatus; checksStatus?: ChecksStatus;
reviewDecision?: ReviewDecision; reviewDecision?: ReviewDecision;