Files
paseo/packages/app/src/git/policy.ts
T
Mohamed BoudraandGitHub ed07452c80 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.
2026-05-09 15:39:26 +08:00

450 lines
14 KiB
TypeScript

import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@server/shared/messages";
export type GitActionId =
| "commit"
| "pull"
| "push"
| "pull-and-push"
| "pr"
| "merge-pr-squash"
| "merge-pr-merge"
| "merge-pr-rebase"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
export interface GitAction {
id: GitActionId;
label: string;
pendingLabel: string;
successLabel: string;
disabled: boolean;
status: ActionStatus;
unavailableMessage?: string;
icon?: ReactElement;
/** When true, a menu separator should be rendered before this item. */
startsGroup: boolean;
handler: () => void;
}
export interface GitActions {
primary: GitAction | null;
secondary: GitAction[];
menu: GitAction[];
}
interface GitActionRuntimeState {
disabled: boolean;
status: ActionStatus;
icon?: ReactElement;
handler: () => void;
}
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
pullRequestState: "open" | "closed" | null;
pullRequestIsDraft: boolean;
pullRequestIsMerged: boolean;
pullRequestMergeable: PullRequestMergeable;
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";
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const FEATURE_ACTION_IDS: GitActionId[] = [
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
];
const MERGE_PR_METHODS: Record<
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
{ label: string; method: CheckoutPrMergeMethod }
> = {
"merge-pr-squash": { label: "Squash and merge", method: "squash" },
"merge-pr-merge": { label: "Create a merge commit", method: "merge" },
"merge-pr-rebase": { label: "Rebase and merge", method: "rebase" },
};
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
if (state === "open") return "open";
if (state === "closed") return "closed";
return null;
}
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
return { primary: null, secondary: [], menu: [] };
}
const allActions = new Map<GitActionId, GitAction>();
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: input.runtime.commit.disabled,
status: input.runtime.commit.status,
icon: input.runtime.commit.icon,
startsGroup: false,
handler: input.runtime.commit.handler,
});
allActions.set("pull", {
id: "pull",
label: "Pull",
pendingLabel: "Pulling...",
successLabel: "Pulled",
disabled: input.runtime.pull.disabled,
status: input.runtime.pull.status,
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
icon: input.runtime.pull.icon,
startsGroup: false,
handler: input.runtime.pull.handler,
});
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: input.runtime.push.disabled,
status: input.runtime.push.status,
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
icon: input.runtime.push.icon,
startsGroup: false,
handler: input.runtime.push.handler,
});
allActions.set("pull-and-push", {
id: "pull-and-push",
label: "Pull and push",
pendingLabel: "Pulling and pushing...",
successLabel: "Pulled and pushed",
disabled: input.runtime["pull-and-push"].disabled,
status: input.runtime["pull-and-push"].status,
unavailableMessage: input.runtime["pull-and-push"].disabled
? undefined
: getPullAndPushUnavailableMessage(input),
icon: input.runtime["pull-and-push"].icon,
startsGroup: false,
handler: input.runtime["pull-and-push"].handler,
});
allActions.set("pr", buildPrAction(input));
allActions.set("merge-pr-squash", buildMergePrAction(input, "merge-pr-squash"));
allActions.set("merge-pr-merge", buildMergePrAction(input, "merge-pr-merge"));
allActions.set("merge-pr-rebase", buildMergePrAction(input, "merge-pr-rebase"));
allActions.set("merge-branch", {
id: "merge-branch",
label: "Merge locally",
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: input.runtime["merge-branch"].disabled,
status: input.runtime["merge-branch"].status,
unavailableMessage: input.runtime["merge-branch"].disabled
? undefined
: getMergeBranchUnavailableMessage(input),
icon: input.runtime["merge-branch"].icon,
startsGroup: false,
handler: input.runtime["merge-branch"].handler,
});
allActions.set("merge-from-base", {
id: "merge-from-base",
label: `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: input.runtime["merge-from-base"].disabled,
status: input.runtime["merge-from-base"].status,
unavailableMessage: input.runtime["merge-from-base"].disabled
? undefined
: getMergeFromBaseUnavailableMessage(input),
icon: input.runtime["merge-from-base"].icon,
startsGroup: true,
handler: input.runtime["merge-from-base"].handler,
});
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled,
status: input.runtime["archive-worktree"].status,
unavailableMessage:
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
? undefined
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
icon: input.runtime["archive-worktree"].icon,
startsGroup: true,
handler: input.runtime["archive-worktree"].handler,
});
const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
const secondaryIds = [...REMOTE_ACTION_IDS];
if (!input.isOnBaseBranch) {
secondaryIds.push(...FEATURE_ACTION_IDS);
}
if (input.isPaseoOwnedWorktree) {
secondaryIds.push("archive-worktree");
}
return {
primary,
secondary: secondaryIds.map((id) => allActions.get(id)!),
menu: [],
};
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
return "archive-worktree";
}
if (input.hasUncommittedChanges) {
return "commit";
}
if (canPull(input)) {
return "pull";
}
if (canPush(input)) {
return "push";
}
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base";
}
if (canMergePr(input) && input.shipDefault === "pr") {
return "merge-pr-squash";
}
if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
return "merge-branch";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
return "pr";
}
return null;
}
function buildPrAction(input: BuildGitActionsInput): GitAction {
if (input.hasPullRequest && input.pullRequestUrl) {
return {
id: "pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
unavailableMessage:
input.runtime.pr.disabled || input.githubFeaturesEnabled
? undefined
: "View PR isn't available right now because GitHub isn't connected",
icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler,
};
}
return {
id: "pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
unavailableMessage: input.runtime.pr.disabled
? undefined
: getCreatePrUnavailableMessage(input),
icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler,
};
}
function buildMergePrAction(
input: BuildGitActionsInput,
id: "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
): GitAction {
const runtime = input.runtime[id];
const config = MERGE_PR_METHODS[id];
const unavailableMessage = getMergePrUnavailableMessage(input);
return {
id,
label: config.label,
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: runtime.disabled || shouldDisableMergePrAction(input),
status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
icon: runtime.icon,
startsGroup: id === "merge-pr-squash",
handler: runtime.handler,
};
}
function canPull(input: BuildGitActionsInput): boolean {
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
}
function canPush(input: BuildGitActionsInput): boolean {
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
}
function canMergeFromBase(input: BuildGitActionsInput): boolean {
return (
!input.isOnBaseBranch &&
input.baseRefAvailable &&
!input.hasUncommittedChanges &&
input.behindBaseCount > 0
);
}
function canMergePr(input: BuildGitActionsInput): boolean {
return (
input.githubFeaturesEnabled &&
input.hasPullRequest &&
input.pullRequestState === "open" &&
!input.pullRequestIsDraft &&
!input.pullRequestIsMerged &&
input.pullRequestMergeable === "MERGEABLE" &&
input.aheadCount > 0 &&
!input.hasUncommittedChanges &&
input.behindOfOrigin === 0 &&
input.aheadOfOrigin === 0 &&
!canMergeFromBase(input)
);
}
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull isn't available here because this branch is not connected to a remote yet";
}
if (input.hasUncommittedChanges) {
return "Pull isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === 0) {
return "Pull isn't available because this branch is already up to date";
}
return undefined;
}
function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Push isn't available here because this branch is not connected to a remote yet";
}
if (input.behindOfOrigin > 0) {
return "Push isn't available yet because there are newer changes to bring in first";
}
if (input.aheadOfOrigin === 0) {
return "Push isn't available because there is nothing new to send";
}
return undefined;
}
function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull and push isn't available here because this branch is not connected to a remote yet";
}
if (input.hasUncommittedChanges) {
return "Pull and push isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return "Pull and push isn't available because this branch is already in sync";
}
return undefined;
}
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "Create PR isn't available right now because GitHub isn't connected";
}
if (input.aheadCount === 0) {
return "Create PR isn't available because this branch doesn't have any new commits yet";
}
return undefined;
}
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Merge isn't available because we couldn't determine the base branch";
}
if (input.hasUncommittedChanges) {
return "Merge isn't available while you have local changes so commit or stash them first";
}
if (input.aheadCount === 0) {
return "Merge isn't available because this branch doesn't have anything new to merge yet";
}
return undefined;
}
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Update isn't available because we couldn't determine the base branch";
}
if (input.hasUncommittedChanges) {
return "Update isn't available while you have local changes so commit or stash them first";
}
if (input.behindBaseCount === 0) {
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
}
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"
);
}