* 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.
189 lines
5.4 KiB
TypeScript
189 lines
5.4 KiB
TypeScript
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useId, useMemo } from "react";
|
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
|
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
|
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
|
|
|
function checkoutDiffQueryKey(
|
|
serverId: string,
|
|
cwd: string,
|
|
mode: "uncommitted" | "base",
|
|
baseRef?: string,
|
|
ignoreWhitespace?: boolean,
|
|
) {
|
|
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
|
}
|
|
|
|
interface UseCheckoutDiffQueryOptions {
|
|
serverId: string;
|
|
cwd: string;
|
|
mode: "uncommitted" | "base";
|
|
baseRef?: string;
|
|
ignoreWhitespace?: boolean;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
|
|
|
export type ParsedDiffFile = CheckoutDiffQueryPayload["files"][number];
|
|
export type DiffHunk = ParsedDiffFile["hunks"][number];
|
|
export type DiffLine = DiffHunk["lines"][number];
|
|
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
|
|
|
function normalizeCheckoutDiffCompare(compare: {
|
|
mode: "uncommitted" | "base";
|
|
baseRef?: string;
|
|
ignoreWhitespace?: boolean;
|
|
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
|
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
|
if (compare.mode === "uncommitted") {
|
|
return { mode: "uncommitted", ignoreWhitespace };
|
|
}
|
|
const trimmedBaseRef = compare.baseRef?.trim();
|
|
return trimmedBaseRef
|
|
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
|
: { mode: "base", ignoreWhitespace };
|
|
}
|
|
|
|
export function useCheckoutDiffQuery({
|
|
serverId,
|
|
cwd,
|
|
mode,
|
|
baseRef,
|
|
ignoreWhitespace,
|
|
enabled = true,
|
|
}: UseCheckoutDiffQueryOptions) {
|
|
const queryClient = useQueryClient();
|
|
const client = useHostRuntimeClient(serverId);
|
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
|
const hookInstanceId = useId();
|
|
const normalizedCompare = useMemo(
|
|
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
|
[mode, baseRef, ignoreWhitespace],
|
|
);
|
|
const compareMode = normalizedCompare.mode;
|
|
const compareBaseRef = normalizedCompare.baseRef;
|
|
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
|
const queryKey = useMemo(
|
|
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
|
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
|
);
|
|
|
|
const query = useQuery<CheckoutDiffQueryPayload>({
|
|
queryKey,
|
|
queryFn: skipToken,
|
|
staleTime: Infinity,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!client || !isConnected || !cwd || !enabled) {
|
|
return;
|
|
}
|
|
|
|
const subscriptionId = [
|
|
"checkoutDiff",
|
|
hookInstanceId,
|
|
serverId,
|
|
cwd,
|
|
compareMode,
|
|
compareBaseRef ?? "",
|
|
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
|
].join(":");
|
|
let cancelled = false;
|
|
|
|
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
|
|
if (message.payload.subscriptionId !== subscriptionId) {
|
|
return;
|
|
}
|
|
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
|
cwd: message.payload.cwd,
|
|
files: orderCheckoutDiffFiles(message.payload.files),
|
|
error: message.payload.error,
|
|
requestId: `subscription:${subscriptionId}`,
|
|
});
|
|
});
|
|
const unsubscribeSubscribeResponse = client.on(
|
|
"subscribe_checkout_diff_response",
|
|
(message) => {
|
|
if (message.payload.subscriptionId !== subscriptionId) {
|
|
return;
|
|
}
|
|
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
|
cwd: message.payload.cwd,
|
|
files: orderCheckoutDiffFiles(message.payload.files),
|
|
error: message.payload.error,
|
|
requestId: message.payload.requestId,
|
|
});
|
|
},
|
|
);
|
|
|
|
void client
|
|
.subscribeCheckoutDiff(
|
|
cwd,
|
|
{
|
|
mode: compareMode,
|
|
baseRef: compareBaseRef,
|
|
ignoreWhitespace: compareIgnoreWhitespace,
|
|
},
|
|
{ subscriptionId },
|
|
)
|
|
.then((payload) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
|
cwd: payload.cwd,
|
|
files: orderCheckoutDiffFiles(payload.files),
|
|
error: payload.error,
|
|
requestId: payload.requestId,
|
|
});
|
|
return;
|
|
})
|
|
.catch((error) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
console.error("[useCheckoutDiffQuery] subscribeCheckoutDiff failed", {
|
|
serverId,
|
|
cwd,
|
|
error,
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
unsubscribeUpdate();
|
|
unsubscribeSubscribeResponse();
|
|
try {
|
|
client.unsubscribeCheckoutDiff(subscriptionId);
|
|
} catch {
|
|
// Ignore disconnect race during effect cleanup.
|
|
}
|
|
};
|
|
}, [
|
|
client,
|
|
isConnected,
|
|
cwd,
|
|
enabled,
|
|
hookInstanceId,
|
|
serverId,
|
|
compareMode,
|
|
compareBaseRef,
|
|
compareIgnoreWhitespace,
|
|
queryKey,
|
|
queryClient,
|
|
]);
|
|
|
|
const payload = query.data ?? null;
|
|
const payloadError = payload?.error ?? null;
|
|
|
|
return {
|
|
files: payload?.files ?? [],
|
|
payloadError,
|
|
isLoading: payload === null && enabled && isConnected,
|
|
isFetching: false,
|
|
isError: Boolean(payloadError),
|
|
error: null,
|
|
};
|
|
}
|