* 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.
318 lines
9.0 KiB
TypeScript
318 lines
9.0 KiB
TypeScript
import React, { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { JSDOM } from "jsdom";
|
|
import {
|
|
QueryClient,
|
|
QueryClientProvider,
|
|
focusManager,
|
|
onlineManager,
|
|
} from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
|
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-status-query";
|
|
|
|
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
|
|
|
const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
|
|
const hoistedHandlers = new Set<(message: unknown) => void>();
|
|
const hoistedClient = {
|
|
getCheckoutStatus: vi.fn(),
|
|
on: vi.fn((type: string, handler: (message: unknown) => void) => {
|
|
if (type !== "checkout_status_update") {
|
|
return () => {};
|
|
}
|
|
hoistedHandlers.add(handler);
|
|
return () => {
|
|
hoistedHandlers.delete(handler);
|
|
};
|
|
}),
|
|
};
|
|
|
|
return {
|
|
mockClient: hoistedClient,
|
|
checkoutStatusUpdateHandlers: hoistedHandlers,
|
|
mockRuntime: {
|
|
client: hoistedClient,
|
|
isConnected: true,
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@/runtime/host-runtime", () => ({
|
|
useHostRuntimeClient: () => mockRuntime.client,
|
|
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
|
|
}));
|
|
|
|
const serverId = "server-1";
|
|
const cwd = "/repo";
|
|
|
|
function checkoutStatus(overrides: Partial<CheckoutStatusPayload> = {}): CheckoutStatusPayload {
|
|
return {
|
|
cwd,
|
|
error: null,
|
|
requestId: "checkout-status-1",
|
|
isGit: true,
|
|
isPaseoOwnedWorktree: false,
|
|
repoRoot: cwd,
|
|
currentBranch: "main",
|
|
isDirty: false,
|
|
baseRef: "origin/main",
|
|
aheadBehind: { ahead: 0, behind: 0 },
|
|
aheadOfOrigin: 0,
|
|
behindOfOrigin: 0,
|
|
hasRemote: true,
|
|
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
|
...overrides,
|
|
} as CheckoutStatusPayload;
|
|
}
|
|
|
|
function createTestQueryClient(): QueryClient {
|
|
return new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderCheckoutStatusHook({
|
|
queryClient = createTestQueryClient(),
|
|
hookCwd = cwd,
|
|
}: {
|
|
queryClient?: QueryClient;
|
|
hookCwd?: string;
|
|
} = {}) {
|
|
let latest: ReturnType<typeof useCheckoutStatusQuery> | null = null;
|
|
|
|
function Probe() {
|
|
latest = useCheckoutStatusQuery({ serverId, cwd: hookCwd });
|
|
return null;
|
|
}
|
|
|
|
const container = document.getElementById("root");
|
|
if (!container) {
|
|
throw new Error("Missing root container");
|
|
}
|
|
|
|
const root = createRoot(container);
|
|
|
|
return {
|
|
get latest() {
|
|
if (!latest) {
|
|
throw new Error("Expected hook result");
|
|
}
|
|
return latest;
|
|
},
|
|
async mount() {
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(
|
|
QueryClientProvider,
|
|
{ client: queryClient },
|
|
React.createElement(Probe),
|
|
),
|
|
);
|
|
});
|
|
},
|
|
async unmount() {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
async function waitForExpectation(assertion: () => void): Promise<void> {
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
try {
|
|
assertion();
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(10);
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
}
|
|
|
|
throw lastError;
|
|
}
|
|
|
|
async function emitCheckoutStatusUpdate(payload: CheckoutStatusPayload): Promise<void> {
|
|
await act(async () => {
|
|
for (const handler of checkoutStatusUpdateHandlers) {
|
|
handler({
|
|
type: "checkout_status_update",
|
|
payload,
|
|
});
|
|
}
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
describe("useCheckoutStatusQuery", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
|
|
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
|
|
url: "http://localhost",
|
|
});
|
|
|
|
Object.defineProperty(globalThis, "document", {
|
|
value: dom.window.document,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(globalThis, "window", {
|
|
value: dom.window,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(globalThis, "navigator", {
|
|
value: dom.window.navigator,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
|
|
mockRuntime.client = mockClient;
|
|
mockRuntime.isConnected = true;
|
|
mockClient.getCheckoutStatus.mockReset();
|
|
mockClient.on.mockClear();
|
|
checkoutStatusUpdateHandlers.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
focusManager.setFocused(undefined);
|
|
onlineManager.setOnline(true);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not schedule polling after the cold read", async () => {
|
|
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
|
|
const hook = renderCheckoutStatusHook();
|
|
|
|
await hook.mount();
|
|
await waitForExpectation(() => {
|
|
expect(hook.latest.status?.currentBranch).toBe("main");
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(60_000);
|
|
});
|
|
|
|
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns the cached snapshot without calling the daemon", async () => {
|
|
const queryClient = createTestQueryClient();
|
|
const cached = checkoutStatus({ requestId: "cached" });
|
|
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached);
|
|
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "uncached" }));
|
|
const hook = renderCheckoutStatusHook({ queryClient });
|
|
|
|
await hook.mount();
|
|
|
|
expect(hook.latest.status).toEqual(cached);
|
|
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetches once on a cold read and uses cache on remount", async () => {
|
|
const queryClient = createTestQueryClient();
|
|
const fetched = checkoutStatus({ requestId: "cold-read" });
|
|
mockClient.getCheckoutStatus.mockResolvedValue(fetched);
|
|
const firstHook = renderCheckoutStatusHook({ queryClient });
|
|
|
|
await firstHook.mount();
|
|
await waitForExpectation(() => {
|
|
expect(firstHook.latest.status).toEqual(fetched);
|
|
});
|
|
await firstHook.unmount();
|
|
|
|
const secondHook = renderCheckoutStatusHook({ queryClient });
|
|
await secondHook.mount();
|
|
|
|
expect(secondHook.latest.status).toEqual(fetched);
|
|
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not refetch checkout status on focus or reconnect", async () => {
|
|
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
|
|
const hook = renderCheckoutStatusHook();
|
|
|
|
await hook.mount();
|
|
await waitForExpectation(() => {
|
|
expect(hook.latest.status?.currentBranch).toBe("main");
|
|
});
|
|
|
|
await act(async () => {
|
|
focusManager.setFocused(false);
|
|
focusManager.setFocused(true);
|
|
onlineManager.setOnline(false);
|
|
onlineManager.setOnline(true);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("server-pushed checkout status update writes into the React Query cache at checkoutStatusQueryKey(serverId, cwd)", async () => {
|
|
const queryClient = createTestQueryClient();
|
|
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
|
|
const hook = renderCheckoutStatusHook({ queryClient });
|
|
|
|
await hook.mount();
|
|
await waitForExpectation(() => {
|
|
expect(hook.latest.status?.requestId).toBe("initial");
|
|
});
|
|
|
|
mockClient.getCheckoutStatus.mockClear();
|
|
const pushed = checkoutStatus({
|
|
requestId: "server-push",
|
|
currentBranch: "pushed-branch",
|
|
isDirty: true,
|
|
aheadBehind: { ahead: 2, behind: 1 },
|
|
aheadOfOrigin: 2,
|
|
behindOfOrigin: 1,
|
|
});
|
|
await emitCheckoutStatusUpdate(pushed);
|
|
|
|
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
|
|
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
|
});
|
|
|
|
it("server push only updates the matching cwd's cache key, not other workspaces", async () => {
|
|
const queryClient = createTestQueryClient();
|
|
const otherCwd = "/other-repo";
|
|
const otherCached = checkoutStatus({
|
|
cwd: otherCwd,
|
|
repoRoot: otherCwd,
|
|
requestId: "other-cached",
|
|
currentBranch: "other-main",
|
|
});
|
|
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached);
|
|
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
|
|
const hook = renderCheckoutStatusHook({ queryClient });
|
|
|
|
await hook.mount();
|
|
await waitForExpectation(() => {
|
|
expect(hook.latest.status?.requestId).toBe("initial");
|
|
});
|
|
|
|
const pushed = checkoutStatus({
|
|
requestId: "server-push",
|
|
currentBranch: "pushed-branch",
|
|
});
|
|
await emitCheckoutStatusUpdate(pushed);
|
|
|
|
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
|
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual(
|
|
otherCached,
|
|
);
|
|
});
|
|
});
|