* 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.
326 lines
9.0 KiB
TypeScript
326 lines
9.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildReviewableDiffTargetKey,
|
|
buildSplitDiffRows,
|
|
buildUnifiedDiffLines,
|
|
} from "./diff-layout";
|
|
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
|
import type { ReviewableDiffTarget } from "./diff-layout";
|
|
|
|
function makeFile(
|
|
lines: ParsedDiffFile["hunks"][number]["lines"],
|
|
options: { oldStart?: number; newStart?: number } = {},
|
|
): ParsedDiffFile {
|
|
const oldStart = options.oldStart ?? 10;
|
|
const newStart = options.newStart ?? 10;
|
|
return {
|
|
path: "example.ts",
|
|
isNew: false,
|
|
isDeleted: false,
|
|
additions: lines.filter((line) => line.type === "add").length,
|
|
deletions: lines.filter((line) => line.type === "remove").length,
|
|
status: "ok",
|
|
hunks: [
|
|
{
|
|
oldStart,
|
|
oldCount: 4,
|
|
newStart,
|
|
newCount: 5,
|
|
lines,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function expectReviewTarget(
|
|
target: ReviewableDiffTarget | null | undefined,
|
|
expected: Omit<ReviewableDiffTarget, "key" | "filePath">,
|
|
) {
|
|
expect(target).toMatchObject({
|
|
filePath: "example.ts",
|
|
...expected,
|
|
});
|
|
expect(target?.key).toBe(
|
|
buildReviewableDiffTargetKey({
|
|
filePath: "example.ts",
|
|
side: expected.side,
|
|
lineNumber: expected.lineNumber,
|
|
}),
|
|
);
|
|
}
|
|
|
|
describe("buildSplitDiffRows", () => {
|
|
it("uses one canonical persisted key for rendered review targets", () => {
|
|
const rows = buildSplitDiffRows(
|
|
makeFile([
|
|
{ type: "header", content: "@@ -10,1 +20,1 @@" },
|
|
{ type: "context", content: "same line" },
|
|
]),
|
|
);
|
|
|
|
if (rows[1]?.kind !== "pair") {
|
|
throw new Error("Expected split pair row");
|
|
}
|
|
expect(rows[1].left?.reviewTarget?.key).toBe("example.ts:old:10");
|
|
expect(rows[1].right?.reviewTarget?.key).toBe("example.ts:new:10");
|
|
});
|
|
|
|
it("pairs replacement runs by index", () => {
|
|
const rows = buildSplitDiffRows(
|
|
makeFile([
|
|
{ type: "header", content: "@@ -10,2 +10,2 @@" },
|
|
{ type: "remove", content: "before one" },
|
|
{ type: "remove", content: "before two" },
|
|
{ type: "add", content: "after one" },
|
|
{ type: "add", content: "after two" },
|
|
]),
|
|
);
|
|
|
|
expect(rows).toHaveLength(3);
|
|
expect(rows[1]).toMatchObject({
|
|
kind: "pair",
|
|
left: { type: "remove", content: "before one", lineNumber: 10 },
|
|
right: { type: "add", content: "after one", lineNumber: 10 },
|
|
});
|
|
expect(rows[2]).toMatchObject({
|
|
kind: "pair",
|
|
left: { type: "remove", content: "before two", lineNumber: 11 },
|
|
right: { type: "add", content: "after two", lineNumber: 11 },
|
|
});
|
|
});
|
|
|
|
it("keeps unmatched additions on the right side only", () => {
|
|
const rows = buildSplitDiffRows(
|
|
makeFile([
|
|
{ type: "header", content: "@@ -10,1 +10,2 @@" },
|
|
{ type: "remove", content: "before" },
|
|
{ type: "add", content: "after one" },
|
|
{ type: "add", content: "after two" },
|
|
]),
|
|
);
|
|
|
|
expect(rows[2]).toMatchObject({
|
|
kind: "pair",
|
|
left: null,
|
|
right: { type: "add", content: "after two", lineNumber: 11 },
|
|
});
|
|
});
|
|
|
|
it("duplicates context rows on both sides", () => {
|
|
const rows = buildSplitDiffRows(
|
|
makeFile([
|
|
{ type: "header", content: "@@ -10,1 +10,1 @@" },
|
|
{ type: "context", content: "same line" },
|
|
]),
|
|
);
|
|
|
|
expect(rows[1]).toMatchObject({
|
|
kind: "pair",
|
|
left: { type: "context", content: "same line", lineNumber: 10 },
|
|
right: { type: "context", content: "same line", lineNumber: 10 },
|
|
});
|
|
});
|
|
|
|
it("emits old and new review targets for split context cells", () => {
|
|
const rows = buildSplitDiffRows(
|
|
makeFile(
|
|
[
|
|
{ type: "header", content: "@@ -10,1 +20,1 @@" },
|
|
{ type: "context", content: "same line" },
|
|
],
|
|
{ newStart: 20 },
|
|
),
|
|
);
|
|
|
|
expect(rows[0]).toEqual({
|
|
kind: "header",
|
|
content: "@@ -10,1 +20,1 @@",
|
|
});
|
|
expect(rows[1]).toMatchObject({
|
|
kind: "pair",
|
|
left: { type: "context", content: "same line", lineNumber: 10 },
|
|
right: { type: "context", content: "same line", lineNumber: 20 },
|
|
});
|
|
if (rows[1]?.kind !== "pair") {
|
|
throw new Error("Expected split pair row");
|
|
}
|
|
expectReviewTarget(rows[1].left?.reviewTarget, {
|
|
hunkHeader: "@@ -10,1 +20,1 @@",
|
|
hunkIndex: 0,
|
|
lineIndex: 1,
|
|
oldLineNumber: 10,
|
|
newLineNumber: 20,
|
|
side: "old",
|
|
lineNumber: 10,
|
|
lineType: "context",
|
|
content: "same line",
|
|
});
|
|
expectReviewTarget(rows[1].right?.reviewTarget, {
|
|
hunkHeader: "@@ -10,1 +20,1 @@",
|
|
hunkIndex: 0,
|
|
lineIndex: 1,
|
|
oldLineNumber: 10,
|
|
newLineNumber: 20,
|
|
side: "new",
|
|
lineNumber: 20,
|
|
lineType: "context",
|
|
content: "same line",
|
|
});
|
|
});
|
|
|
|
it("does not emit review targets for split headers or empty cells", () => {
|
|
const rows = buildSplitDiffRows(
|
|
makeFile([
|
|
{ type: "header", content: "@@ -10,1 +10,2 @@" },
|
|
{ type: "add", content: "after" },
|
|
]),
|
|
);
|
|
|
|
expect(rows[0]).toEqual({
|
|
kind: "header",
|
|
content: "@@ -10,1 +10,2 @@",
|
|
});
|
|
expect(rows[1]).toMatchObject({
|
|
kind: "pair",
|
|
left: null,
|
|
right: { type: "add", content: "after", lineNumber: 10 },
|
|
});
|
|
if (rows[1]?.kind !== "pair") {
|
|
throw new Error("Expected split pair row");
|
|
}
|
|
expectReviewTarget(rows[1].right?.reviewTarget, {
|
|
hunkHeader: "@@ -10,1 +10,2 @@",
|
|
hunkIndex: 0,
|
|
lineIndex: 1,
|
|
oldLineNumber: null,
|
|
newLineNumber: 10,
|
|
side: "new",
|
|
lineNumber: 10,
|
|
lineType: "add",
|
|
content: "after",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("buildUnifiedDiffLines", () => {
|
|
it("computes line numbers per line type within a hunk", () => {
|
|
const lines = buildUnifiedDiffLines(
|
|
makeFile([
|
|
{ type: "header", content: "@@ -10,3 +10,4 @@" },
|
|
{ type: "context", content: "before" },
|
|
{ type: "add", content: "inserted" },
|
|
{ type: "remove", content: "removed" },
|
|
{ type: "context", content: "after" },
|
|
]),
|
|
);
|
|
|
|
expect(
|
|
lines.map(({ line, lineNumber }) => ({
|
|
type: line.type,
|
|
lineNumber,
|
|
content: line.content,
|
|
})),
|
|
).toEqual([
|
|
{ type: "header", lineNumber: null, content: "@@ -10,3 +10,4 @@" },
|
|
{ type: "context", lineNumber: 10, content: "before" },
|
|
{ type: "add", lineNumber: 11, content: "inserted" },
|
|
{ type: "remove", lineNumber: 11, content: "removed" },
|
|
{ type: "context", lineNumber: 12, content: "after" },
|
|
]);
|
|
});
|
|
|
|
it("restarts numbering at each hunk boundary", () => {
|
|
const file: ParsedDiffFile = {
|
|
path: "example.ts",
|
|
isNew: false,
|
|
isDeleted: false,
|
|
additions: 1,
|
|
deletions: 0,
|
|
status: "ok",
|
|
hunks: [
|
|
{
|
|
oldStart: 75,
|
|
oldCount: 2,
|
|
newStart: 75,
|
|
newCount: 3,
|
|
lines: [
|
|
{ type: "header", content: "@@ -75,2 +75,3 @@" },
|
|
{ type: "context", content: "first" },
|
|
{ type: "add", content: "inserted" },
|
|
{ type: "context", content: "second" },
|
|
],
|
|
},
|
|
{
|
|
oldStart: 165,
|
|
oldCount: 2,
|
|
newStart: 166,
|
|
newCount: 2,
|
|
lines: [
|
|
{ type: "header", content: "@@ -165,2 +166,2 @@" },
|
|
{ type: "context", content: "third" },
|
|
{ type: "context", content: "fourth" },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const lines = buildUnifiedDiffLines(file);
|
|
|
|
expect(lines[0]?.lineNumber).toBeNull();
|
|
expect(lines[1]?.lineNumber).toBe(75);
|
|
expect(lines[3]?.lineNumber).toBe(77);
|
|
expect(lines[4]?.lineNumber).toBeNull();
|
|
expect(lines[5]?.lineNumber).toBe(166);
|
|
expect(lines[6]?.lineNumber).toBe(167);
|
|
});
|
|
|
|
it("emits canonical review targets for unified add, remove, and context lines", () => {
|
|
const lines = buildUnifiedDiffLines(
|
|
makeFile(
|
|
[
|
|
{ type: "header", content: "@@ -10,2 +20,2 @@" },
|
|
{ type: "context", content: "before" },
|
|
{ type: "remove", content: "removed" },
|
|
{ type: "add", content: "inserted" },
|
|
],
|
|
{ newStart: 20 },
|
|
),
|
|
);
|
|
|
|
expect(lines[0]?.reviewTarget).toBeNull();
|
|
expectReviewTarget(lines[1]?.reviewTarget, {
|
|
hunkHeader: "@@ -10,2 +20,2 @@",
|
|
hunkIndex: 0,
|
|
lineIndex: 1,
|
|
oldLineNumber: 10,
|
|
newLineNumber: 20,
|
|
side: "new",
|
|
lineNumber: 20,
|
|
lineType: "context",
|
|
content: "before",
|
|
});
|
|
expectReviewTarget(lines[2]?.reviewTarget, {
|
|
hunkHeader: "@@ -10,2 +20,2 @@",
|
|
hunkIndex: 0,
|
|
lineIndex: 2,
|
|
oldLineNumber: 11,
|
|
newLineNumber: null,
|
|
side: "old",
|
|
lineNumber: 11,
|
|
lineType: "remove",
|
|
content: "removed",
|
|
});
|
|
expectReviewTarget(lines[3]?.reviewTarget, {
|
|
hunkHeader: "@@ -10,2 +20,2 @@",
|
|
hunkIndex: 0,
|
|
lineIndex: 3,
|
|
oldLineNumber: null,
|
|
newLineNumber: 21,
|
|
side: "new",
|
|
lineNumber: 21,
|
|
lineType: "add",
|
|
content: "inserted",
|
|
});
|
|
});
|
|
});
|