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

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

Adds a `checkoutPrMerge` server RPC backed by `gh pr merge` (squash, merge,
or rebase) and surfaces three new actions in the git-actions menu —
"Squash and merge", "Create a merge commit", "Rebase and merge" — with
squash taking the primary slot when a PR is open and mergeable. Also
renames the existing local merge action to "Merge locally" to disambiguate
it from the new GitHub-side merge.

Adds an end-to-end test that creates a real temporary GitHub repo, opens a
PR via the daemon, polls until mergeable, calls `checkoutPrMerge`, and
verifies the merge landed on `main` — no mocks. The test pre-flights the
`delete_repo` scope so it skips cleanly on tokens that can't tear down,
and fails loud if cleanup ever errors so we don't leak repos.

PR status gains an optional `mergeable` field (`MERGEABLE | CONFLICTING |
UNKNOWN`) populated from `gh pr view --json mergeable`. The schema is
backward compatible — old payloads without the field still parse and
default to `UNKNOWN` via `z.catch`.

* Update PR status snapshot expectations

* refactor: collect git/checkout/PR feature into packages/app/src/git/

Move 21 git/checkout/PR files out of components/, hooks/, stores/, screens/,
utils/ flat-peer slop into a single packages/app/src/git/ module with
shrunk filenames (git-diff-pane.tsx -> diff-pane.tsx, use-git-actions.ts ->
use-actions.ts, etc).

Dedupe: drop the duplicate useGitActionHandlers from diff-pane.tsx — the
shared useGitActions hook is the single source. Drop the
buildGitActionsForPane wrapper. Collapse the merge-PR triple fan-out
(mergePrSquash/Merge/RebaseStatus) into one record keyed by
CheckoutPrMergeMethod. Streamline server-side single-use helpers
(dispatchStash/PullRequestMessage adapters, GitHubPullRequestMergeError
wrapper, legacy worktree test helpers).

Schema-additive only: messages.ts exports CheckoutPrMergeMethod and
PullRequestMergeable types; daemon-client.ts uses the typed alias.
Backward-compatible.

Net delta: 43 files, +219/-1212.

* fix(app): use GitHub icon for all merge-PR menu actions

* test: namespace temp GitHub repos under `paseotmp-` and centralize naming

Old prefixes (`paseo-checkout-ship-`, `paseo-e2e-`, `paseo-checkout-pr-merge-`)
collided with the `paseo-*` namespace of real repos, making bulk cleanup
unsafe. Switch every test that creates real GitHub repos to a single
unmistakable `paseotmp-` prefix, owned by one shared helper per test
boundary so no caller can name a repo outside the namespace.

- New `packages/server/src/server/test-utils/temp-github-repo.ts` exports
  `TEMP_GITHUB_REPO_PREFIX` + `createTempGithubRepoName(category)`; both
  server e2e tests (checkout-ship, checkout-pr-merge) consume it.
- `packages/app/e2e/helpers/github-fixtures.ts` locks the prefix internally
  and now requires callers to pass a `category` instead of an arbitrary
  `prefix`. Updated pr-pane and composer-attachments specs accordingly.
This commit is contained in:
Mohamed Boudra
2026-05-09 15:39:26 +08:00
committed by GitHub
parent c097678cdf
commit ed07452c80
63 changed files with 1204 additions and 907 deletions
@@ -24,6 +24,7 @@ const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
interface RunnerCall {
args: string[];
cwd: string;
envOverlay?: Record<string, string>;
}
interface TestRunner {
@@ -46,7 +47,7 @@ function createRunner(stdoutByCall: string[]): TestRunner {
return {
calls,
runner: async (args: string[], options: GitHubCommandRunnerOptions) => {
calls.push({ args, cwd: options.cwd });
calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
const stdout = stdoutByCall.shift() ?? "[]";
return { stdout, stderr: "" };
},
@@ -60,7 +61,7 @@ function createScriptedRunner(steps: RunnerStep[]): TestRunner {
return {
calls,
runner: async (args: string[], options: GitHubCommandRunnerOptions) => {
calls.push({ args, cwd: options.cwd });
calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
const step = steps.shift() ?? "";
if (typeof step === "string") {
return { stdout: step, stderr: "" };
@@ -81,7 +82,7 @@ function createDeferredRunner(): TestRunner {
return {
calls,
runner: (args: string[], options: GitHubCommandRunnerOptions) => {
calls.push({ args, cwd: options.cwd });
calls.push({ args, cwd: options.cwd, envOverlay: options.envOverlay });
return new Promise((resolve) => {
resolveNext = (stdout: string) => resolve({ stdout, stderr: "" });
});
@@ -125,6 +126,7 @@ function createCurrentPullRequestStatus(
headRefName: "feature",
isMerged: false,
isDraft: false,
mergeable: "MERGEABLE",
checks: [],
checksStatus: "none",
reviewDecision: null,
@@ -284,6 +286,33 @@ describe("GitHubService", () => {
vi.useRealTimers();
});
it.each([
["merge", ["pr", "merge", "42", "--merge"]],
["squash", ["pr", "merge", "42", "--squash"]],
["rebase", ["pr", "merge", "42", "--rebase"]],
] as const)("merges pull requests with gh using %s", async (mergeMethod, expectedArgs) => {
const runner = createRunner([""]);
const service = createGitHubService({
runner: runner.runner,
});
await expect(
service.mergePullRequest({
cwd: "/tmp/repo",
prNumber: 42,
mergeMethod,
}),
).resolves.toEqual({ success: true });
expect(runner.calls).toEqual([
{
args: expectedArgs,
cwd: "/tmp/repo",
envOverlay: { GH_PROMPT_DISABLED: "1" },
},
]);
});
it("computes fast cadence for pending and slow cadence for stable PR states", () => {
const pendingStatus = createCurrentPullRequestStatus({ checksStatus: "pending" });
const runningCheckStatus = createCurrentPullRequestStatus({
@@ -1048,7 +1077,7 @@ describe("GitHubService", () => {
"pr",
"view",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
]);
expect(status).toEqual({
number: 42,
@@ -1061,6 +1090,7 @@ describe("GitHubService", () => {
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
mergeable: "UNKNOWN",
checks: [
{
name: "server-tests",
@@ -1080,6 +1110,25 @@ describe("GitHubService", () => {
});
});
it("defaults unexpected PR mergeability values to unknown", async () => {
const runner = createRunner([
currentPullRequestJson({
mergeable: "",
}),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
});
expect(status?.mergeable).toBe("UNKNOWN");
});
it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
@@ -1145,7 +1194,7 @@ describe("GitHubService", () => {
"pr",
"view",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
],
["repo", "view", "--json", "owner,name,parent"],
[
@@ -1158,7 +1207,7 @@ describe("GitHubService", () => {
"--head",
"forkOwner:feature/fork",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
"--limit",
"10",
],
@@ -1218,7 +1267,7 @@ describe("GitHubService", () => {
"pr",
"view",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
],
["repo", "view", "--json", "owner,name,parent"],
]);
@@ -1279,7 +1328,7 @@ describe("GitHubService", () => {
"pr",
"view",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
],
[
"pr",
@@ -1289,7 +1338,7 @@ describe("GitHubService", () => {
"--head",
"main",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,mergeable,headRepositoryOwner",
"--limit",
"10",
],
@@ -1452,6 +1501,7 @@ describe("GitHubService", () => {
headRefName: "feature",
isMerged: false,
isDraft: true,
mergeable: "UNKNOWN",
checks: [
{
name: "server-tests",