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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user