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
@@ -30,6 +30,8 @@ import type {
CheckoutPullResponse,
CheckoutPushResponse,
CheckoutPrCreateResponse,
CheckoutPrMergeResponse,
CheckoutPrMergeMethod,
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
CheckoutSwitchBranchResponse,
@@ -263,6 +265,7 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type PullRequestTimelinePayload = PullRequestTimelineResponse["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> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -282,6 +282,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
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 { execSync } from "child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import {
createDaemonTestContext,
createTempGithubRepoName,
type DaemonTestContext,
} from "../test-utils/index.js";
import {
createWorktree as createWorktreePrimitive,
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 {
return execSync("gh api user --jq .login", { stdio: "pipe" }).toString().trim();
}
@@ -128,7 +127,7 @@ describe("daemon checkout ship loop", () => {
initGitRepo(repoDir);
const owner = getGhLogin();
const repoName = createTempRepoName();
const repoName = createTempGithubRepoName("checkout-ship");
repoFullName = `${owner}/${repoName}`;
createPrivateRepo(repoName);
@@ -498,6 +498,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -42,6 +42,7 @@ function createResolverHarness(): ResolverHarness {
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -65,6 +65,7 @@ interface SessionHandlerInternals {
handleCheckoutMergeFromBaseRequest(params: unknown): Promise<unknown>;
handleCheckoutCommitRequest(params: unknown): Promise<unknown>;
handleCheckoutPrCreateRequest(params: unknown): Promise<unknown>;
handleCheckoutPrMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
@@ -288,6 +289,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
invalidate: vi.fn(),
searchIssuesAndPrs: vi.fn(),
createPullRequest: vi.fn(),
mergePullRequest: vi.fn(),
};
const checkoutDiffManager = options.checkoutDiffManager ?? {
scheduleRefreshForCwd: vi.fn(),
@@ -1022,6 +1024,7 @@ describe("session PR status payload normalization", () => {
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: true,
mergeable: "MERGEABLE",
checks: [
{
name: "typecheck",
@@ -1037,6 +1040,7 @@ describe("session PR status payload normalization", () => {
expect(payload).toHaveProperty("repoOwner", "internal-owner");
expect(payload).toHaveProperty("repoName", "internal-repo");
expect(payload).toHaveProperty("mergeable", "MERGEABLE");
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", () => {
test("forces workspace git and GitHub refresh after pulling", async () => {
const messages: unknown[] = [];
+46
View File
@@ -270,6 +270,7 @@ type GitMutationRefreshReason =
| "push"
| "merge-to-base"
| "merge-from-base"
| "merge-pr"
| "create-pr"
| "switch-branch"
| "create-branch"
@@ -2011,6 +2012,7 @@ export class Session {
});
}
// eslint-disable-next-line complexity
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "checkout_status_request":
@@ -2046,6 +2048,8 @@ export class Session {
return this.handleCheckoutPushRequest(msg);
case "checkout_pr_create_request":
return this.handleCheckoutPrCreateRequest(msg);
case "checkout_pr_merge_request":
return this.handleCheckoutPrMergeRequest(msg);
case "checkout_pr_status_request":
return this.handleCheckoutPrStatusRequest(msg);
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(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>,
): Promise<void> {
@@ -8755,6 +8800,7 @@ export function normalizeCheckoutPrStatusPayload(
headRefName: status.headRefName,
isMerged: status.isMerged,
isDraft: status.isDraft ?? false,
mergeable: status.mergeable ?? "UNKNOWN",
checks: status.checks ?? [],
checksStatus: status.checksStatus,
reviewDecision: status.reviewDecision,
@@ -472,6 +472,7 @@ describe("workspace git watch targets", () => {
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: false,
mergeable: "UNKNOWN",
isDraft: false,
checks: [
{
@@ -531,6 +532,7 @@ describe("workspace git watch targets", () => {
baseRefName: "main",
headRefName: "workspace-git-service",
isMerged: true,
mergeable: "UNKNOWN",
isDraft: false,
checks: [],
checksStatus: undefined,
@@ -9,3 +9,4 @@ export {
} from "./daemon-client.js";
export { createDaemonTestContext, type DaemonTestContext } from "./daemon-test-context.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",
number: 1,
})),
mergePullRequest: vi.fn(async () => ({ success: true })),
isAuthenticated: vi.fn(async () => true),
invalidate: vi.fn(),
};
@@ -176,6 +176,7 @@ function createGitHubServiceStub(): GitHubService {
url: "https://github.com/acme/repo/pull/1",
number: 1,
})),
mergePullRequest: vi.fn(async () => ({ success: true })),
isAuthenticated: vi.fn(async () => true),
invalidate: vi.fn(),
};
@@ -20,7 +20,11 @@ import {
resolveBranchCheckout,
resolveAbsoluteGitDir,
} 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 { runGitCommand } from "../utils/run-git-command.js";
import { resolveGitHubRemote, type GitHubRemoteIdentity } from "../utils/github-remote.js";
@@ -78,6 +82,7 @@ export interface WorkspaceGitRuntimeSnapshot {
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
checks?: Array<{
name: string;
status: "success" | "failure" | "pending" | "skipped" | "cancelled";
@@ -39,6 +39,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -149,6 +149,7 @@ function createGitHubServiceStub(): GitHubService {
number: 1,
url: "https://github.com/acme/repo/pull/1",
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -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",
+30 -2
View File
@@ -91,6 +91,8 @@ const HeadRepositoryOwnerSchema = z
.nullable()
.optional();
const PullRequestMergeableSchema = z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN");
const CurrentPullRequestStatusSchema = z.object({
number: z.number().optional(),
url: z.string().catch(""),
@@ -102,6 +104,7 @@ const CurrentPullRequestStatusSchema = z.object({
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
headRepositoryOwner: HeadRepositoryOwnerSchema,
});
@@ -234,7 +237,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
}`;
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 = `
query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
@@ -290,6 +293,7 @@ interface GitHubServiceDependencies {
export interface GitHubCommandRunnerOptions {
cwd: string;
envOverlay?: Record<string, string>;
}
export interface GitHubCommandResult {
@@ -346,6 +350,7 @@ export interface PullRequestCheck {
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
export type PullRequestReviewDecision = "approved" | "changes_requested" | "pending" | null;
export type PullRequestMergeable = "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
export interface GitHubCurrentPullRequestStatus {
number?: number;
@@ -358,6 +363,7 @@ export interface GitHubCurrentPullRequestStatus {
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
mergeable: PullRequestMergeable;
checks: PullRequestCheck[];
checksStatus: PullRequestChecksStatus;
reviewDecision: PullRequestReviewDecision;
@@ -404,6 +410,18 @@ export interface GitHubPullRequestCreateResult {
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 =
| {
force?: false;
@@ -492,6 +510,7 @@ export interface GitHubService {
createPullRequest(
options: CreateGitHubPullRequestOptions,
): Promise<GitHubPullRequestCreateResult>;
mergePullRequest(options: MergeGitHubPullRequestOptions): Promise<GitHubPullRequestMergeResult>;
isAuthenticated(options: { cwd: string } & GitHubReadOptions): Promise<boolean>;
retainCurrentPullRequestStatusPoll?(options: {
cwd: string;
@@ -1030,6 +1049,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
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) {
return cached({
cwd: input.cwd,
@@ -1167,7 +1194,7 @@ async function runGhCommand(
): Promise<GitHubCommandResult> {
return execCommand("gh", args, {
cwd: options.cwd,
envOverlay: GITHUB_ENV,
envOverlay: { ...GITHUB_ENV, ...options.envOverlay },
maxBuffer: 10 * 1024 * 1024,
});
}
@@ -1690,6 +1717,7 @@ function toCurrentPullRequestStatus(
headRefName: item.headRefName || fallbackHeadRefName,
isMerged: mergedAt !== null,
isDraft: item.isDraft ?? false,
mergeable: item.mergeable,
checks,
checksStatus: computeChecksStatus(checks),
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();
});
});
+28
View File
@@ -1272,6 +1272,13 @@ export const CheckoutPrCreateRequestSchema = z.object({
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({
type: z.literal("checkout_pr_status_request"),
cwd: z.string(),
@@ -1719,6 +1726,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPullRequestSchema,
CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrMergeRequestSchema,
CheckoutPrStatusRequestSchema,
PullRequestTimelineRequestSchema,
CheckoutSwitchBranchRequestSchema,
@@ -2661,6 +2669,11 @@ export const CheckoutPrStatusSchema = z.object({
headRefName: z.string(),
isMerged: z.boolean(),
isDraft: z.boolean().optional().default(false),
mergeable: z
.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"])
.catch("UNKNOWN")
.optional()
.default("UNKNOWN"),
checks: z
.array(
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({
type: z.literal("checkout_pr_status_response"),
payload: CheckoutPrStatusPayloadSchema,
@@ -3338,6 +3361,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPullResponseSchema,
CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrMergeResponseSchema,
CheckoutPrStatusResponseSchema,
PullRequestTimelineResponseSchema,
CheckoutSwitchBranchResponseSchema,
@@ -3585,6 +3609,10 @@ export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
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 CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
export type PullRequestTimelineRequest = z.infer<typeof PullRequestTimelineRequestSchema>;
@@ -124,6 +124,7 @@ function createGitHubServiceForStatus(
url: "https://github.com/getpaseo/paseo/pull/1",
number: 1,
}),
mergePullRequest: async () => ({ success: true }),
isAuthenticated: async () => true,
invalidate: () => {},
};
@@ -12,6 +12,7 @@ import {
createGitHubService,
resolveGitHubRepo,
type GitHubService,
type PullRequestMergeable,
} from "../services/github-service.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js";
@@ -2309,6 +2310,7 @@ export interface PullRequestStatus {
headRefName: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
checks?: PullRequestCheck[];
checksStatus?: ChecksStatus;
reviewDecision?: ReviewDecision;