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