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:
@@ -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);
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
+2
-4
@@ -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"
|
||||||
+1
-1
@@ -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,
|
||||||
+24
@@ -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
-1
@@ -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 {
|
||||||
+29
-712
@@ -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
-1
@@ -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
-1
@@ -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([
|
||||||
+202
-1
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+122
-3
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
@@ -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,
|
||||||
+1
-1
@@ -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,
|
||||||
+1
-1
@@ -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"];
|
||||||
|
|
||||||
+5
-2
@@ -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",
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,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,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[] = [];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user