mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
* 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.
268 lines
8.1 KiB
TypeScript
268 lines
8.1 KiB
TypeScript
import { useCallback, useMemo } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
ActivityIndicator,
|
|
Pressable,
|
|
type PressableStateCallbackType,
|
|
} from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Shortcut } from "@/components/ui/shortcut";
|
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
import type { GitAction, GitActions } from "@/git/policy";
|
|
|
|
interface GitActionsSplitButtonProps {
|
|
gitActions: GitActions;
|
|
hideLabels?: boolean;
|
|
}
|
|
|
|
interface GitActionMenuItemProps {
|
|
action: GitAction;
|
|
onSelect: (action: GitAction) => void;
|
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
needsSeparator?: boolean;
|
|
showSeparator?: boolean;
|
|
closeOnSelect?: boolean;
|
|
}
|
|
|
|
function GitActionMenuItem({
|
|
action,
|
|
onSelect,
|
|
archiveShortcutKeys,
|
|
needsSeparator,
|
|
showSeparator,
|
|
closeOnSelect,
|
|
}: GitActionMenuItemProps) {
|
|
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
|
|
const trailing = useMemo(
|
|
() =>
|
|
action.id === "archive-worktree" && archiveShortcutKeys ? (
|
|
<Shortcut chord={archiveShortcutKeys} />
|
|
) : undefined,
|
|
[action.id, archiveShortcutKeys],
|
|
);
|
|
return (
|
|
<View>
|
|
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
|
|
<DropdownMenuItem
|
|
testID={`changes-menu-${action.id}`}
|
|
leading={action.icon}
|
|
trailing={trailing}
|
|
disabled={action.disabled}
|
|
muted={Boolean(action.unavailableMessage)}
|
|
status={action.status}
|
|
pendingLabel={action.pendingLabel}
|
|
successLabel={action.successLabel}
|
|
closeOnSelect={closeOnSelect}
|
|
onSelect={handleSelect}
|
|
>
|
|
{action.label}
|
|
</DropdownMenuItem>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
|
|
const { theme } = useUnistyles();
|
|
const toast = useToast();
|
|
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
|
|
|
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
|
if (action.status === "pending") return action.pendingLabel;
|
|
if (action.status === "success") return action.successLabel;
|
|
return action.label;
|
|
}, []);
|
|
|
|
const handleActionSelect = useCallback(
|
|
(action: GitAction) => {
|
|
if (action.unavailableMessage) {
|
|
toast.show(action.unavailableMessage, {
|
|
durationMs: 3200,
|
|
icon: <Info size={16} color={theme.colors.foreground} />,
|
|
});
|
|
return;
|
|
}
|
|
action.handler();
|
|
},
|
|
[theme.colors.foreground, toast],
|
|
);
|
|
|
|
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
|
|
|
|
const primaryDisabled = gitActions.primary?.disabled;
|
|
const primaryPressableStyle = useCallback(
|
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.splitButtonPrimary,
|
|
(Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
|
|
primaryDisabled && styles.splitButtonPrimaryDisabled,
|
|
],
|
|
[primaryDisabled],
|
|
);
|
|
|
|
const caretTriggerStyle = useCallback(
|
|
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
|
|
styles.splitButtonCaret,
|
|
(hovered || pressed || open) && styles.splitButtonCaretHovered,
|
|
],
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<View style={styles.row}>
|
|
{gitActions.primary ? (
|
|
<View style={styles.splitButton}>
|
|
<Pressable
|
|
testID="changes-primary-cta"
|
|
style={primaryPressableStyle}
|
|
onPress={gitActions.primary.handler}
|
|
disabled={gitActions.primary.disabled}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={gitActions.primary.label}
|
|
>
|
|
{gitActions.primary.status === "pending" ? (
|
|
<ActivityIndicator
|
|
size="small"
|
|
color={theme.colors.foreground}
|
|
style={styles.splitButtonSpinnerOnly}
|
|
/>
|
|
) : (
|
|
<View style={styles.splitButtonContent}>
|
|
{gitActions.primary.icon}
|
|
{!hideLabels && (
|
|
<Text style={styles.splitButtonText}>
|
|
{getActionDisplayLabel(gitActions.primary)}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
{gitActions.secondary.length > 0 ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
testID="changes-primary-cta-caret"
|
|
style={caretTriggerStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="More options"
|
|
>
|
|
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
|
{gitActions.secondary.map((action, index) => (
|
|
<GitActionMenuItem
|
|
key={action.id}
|
|
action={action}
|
|
onSelect={handleActionSelect}
|
|
archiveShortcutKeys={archiveShortcutKeys}
|
|
needsSeparator={action.startsGroup}
|
|
showSeparator={index > 0}
|
|
closeOnSelect={
|
|
action.status === "idle" && action.id === "pr" && action.label === "View PR"
|
|
}
|
|
/>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
{gitActions.menu.length > 0 ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
testID="changes-overflow-menu"
|
|
hitSlop={8}
|
|
style={overflowMenuButtonStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="More actions"
|
|
>
|
|
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
|
|
{gitActions.menu.map((action) => (
|
|
<GitActionMenuItem
|
|
key={action.id}
|
|
action={action}
|
|
onSelect={handleActionSelect}
|
|
closeOnSelect={false}
|
|
/>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
row: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flexShrink: 0,
|
|
},
|
|
splitButton: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
borderRadius: theme.borderRadius.md,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.borderAccent,
|
|
overflow: "hidden",
|
|
},
|
|
splitButtonPrimary: {
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[1],
|
|
justifyContent: "center",
|
|
position: "relative",
|
|
},
|
|
splitButtonPrimaryHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
splitButtonPrimaryDisabled: {
|
|
opacity: 0.6,
|
|
},
|
|
splitButtonText: {
|
|
fontSize: theme.fontSize.sm,
|
|
lineHeight: theme.fontSize.sm * 1.5,
|
|
color: theme.colors.foreground,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
splitButtonContent: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
splitButtonSpinnerOnly: {
|
|
transform: [{ scale: 0.8 }],
|
|
},
|
|
splitButtonCaret: {
|
|
width: 28,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderLeftWidth: theme.borderWidth[1],
|
|
borderLeftColor: theme.colors.borderAccent,
|
|
},
|
|
splitButtonCaretHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
iconButton: {
|
|
width: 32,
|
|
height: 32,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderRadius: theme.borderRadius.md,
|
|
},
|
|
overflowMenuButton: {
|
|
marginRight: -theme.spacing[2],
|
|
},
|
|
}));
|