Files
paseo/packages/app/src/utils/diff-layout.ts
T
Mohamed BoudraandGitHub ed07452c80 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.
2026-05-09 15:39:26 +08:00

300 lines
7.4 KiB
TypeScript

import type { DiffLine, ParsedDiffFile } from "@/git/use-diff-query";
type ReviewSide = "old" | "new";
type ReviewableLineType = "add" | "remove" | "context";
export interface ReviewableDiffTargetKeyInput {
filePath: string;
side: ReviewSide;
lineNumber: number;
}
export interface ReviewableDiffTarget {
key: string;
filePath: string;
hunkHeader: string;
hunkIndex: number;
lineIndex: number;
oldLineNumber: number | null;
newLineNumber: number | null;
side: ReviewSide;
lineNumber: number;
lineType: ReviewableLineType;
content: string;
}
export function buildReviewableDiffTargetKey(input: ReviewableDiffTargetKeyInput): string {
return `${input.filePath}:${input.side}:${input.lineNumber}`;
}
export interface NumberedDiffCell extends ReviewableDiffTarget {
line: DiffLine;
}
export interface NumberedDiffLine {
key: string;
filePath: string;
hunkHeader: string;
hunkIndex: number;
lineIndex: number;
line: DiffLine;
oldLineNumber: number | null;
newLineNumber: number | null;
unifiedCell: NumberedDiffCell | null;
oldCell: NumberedDiffCell | null;
newCell: NumberedDiffCell | null;
}
export interface NumberedDiffHunk {
hunkIndex: number;
hunkHeader: string;
lines: NumberedDiffLine[];
}
export interface SplitDiffDisplayLine {
type: DiffLine["type"];
content: string;
tokens?: DiffLine["tokens"];
lineNumber: number | null;
reviewTarget: ReviewableDiffTarget | null;
}
export interface UnifiedDiffDisplayLine {
key: string;
line: DiffLine;
lineNumber: number | null;
reviewTarget: ReviewableDiffTarget | null;
}
export type SplitDiffRow =
| {
kind: "header";
content: string;
}
| {
kind: "pair";
left: SplitDiffDisplayLine | null;
right: SplitDiffDisplayLine | null;
};
function toSplitDisplayLine(cell: NumberedDiffCell | null): SplitDiffDisplayLine | null {
if (!cell) {
return null;
}
return {
type: cell.lineType,
content: cell.content,
...(cell.line.tokens ? { tokens: cell.line.tokens } : {}),
lineNumber: cell.lineNumber,
reviewTarget: toReviewTarget(cell),
};
}
function toReviewTarget(cell: NumberedDiffCell): ReviewableDiffTarget {
return {
key: cell.key,
filePath: cell.filePath,
hunkHeader: cell.hunkHeader,
hunkIndex: cell.hunkIndex,
lineIndex: cell.lineIndex,
oldLineNumber: cell.oldLineNumber,
newLineNumber: cell.newLineNumber,
side: cell.side,
lineNumber: cell.lineNumber,
lineType: cell.lineType,
content: cell.content,
};
}
function getHunkHeader(hunk: ParsedDiffFile["hunks"][number]): string {
const headerLine = hunk.lines.find((line) => line.type === "header");
return headerLine?.content ?? "@@";
}
export function buildNumberedDiffHunks(file: ParsedDiffFile): NumberedDiffHunk[] {
const numberedHunks: NumberedDiffHunk[] = [];
for (const [hunkIndex, hunk] of file.hunks.entries()) {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
const hunkHeader = getHunkHeader(hunk);
const lines: NumberedDiffLine[] = [];
for (const [lineIndex, line] of hunk.lines.entries()) {
let oldLineNumber: number | null = null;
let newLineNumber: number | null = null;
if (line.type === "remove") {
oldLineNumber = oldLineNo;
oldLineNo += 1;
} else if (line.type === "add") {
newLineNumber = newLineNo;
newLineNo += 1;
} else if (line.type === "context") {
oldLineNumber = oldLineNo;
newLineNumber = newLineNo;
oldLineNo += 1;
newLineNo += 1;
}
const oldCell = buildNumberedCell({
filePath: file.path,
hunkHeader,
hunkIndex,
lineIndex,
line,
oldLineNumber,
newLineNumber,
side: "old",
});
const newCell = buildNumberedCell({
filePath: file.path,
hunkHeader,
hunkIndex,
lineIndex,
line,
oldLineNumber,
newLineNumber,
side: "new",
});
lines.push({
key: `${hunkIndex}-${lineIndex}`,
filePath: file.path,
hunkHeader,
hunkIndex,
lineIndex,
line,
oldLineNumber,
newLineNumber,
unifiedCell: line.type === "remove" ? oldCell : newCell,
oldCell,
newCell,
});
}
numberedHunks.push({ hunkIndex, hunkHeader, lines });
}
return numberedHunks;
}
function buildNumberedCell(input: {
filePath: string;
hunkHeader: string;
hunkIndex: number;
lineIndex: number;
line: DiffLine;
oldLineNumber: number | null;
newLineNumber: number | null;
side: ReviewSide;
}): NumberedDiffCell | null {
if (input.line.type === "header") {
return null;
}
if (input.line.type === "remove" && input.side !== "old") {
return null;
}
if (input.line.type === "add" && input.side !== "new") {
return null;
}
const lineNumber = input.side === "old" ? input.oldLineNumber : input.newLineNumber;
if (lineNumber === null) {
return null;
}
return {
key: buildReviewableDiffTargetKey({
filePath: input.filePath,
side: input.side,
lineNumber,
}),
filePath: input.filePath,
hunkHeader: input.hunkHeader,
hunkIndex: input.hunkIndex,
lineIndex: input.lineIndex,
oldLineNumber: input.oldLineNumber,
newLineNumber: input.newLineNumber,
side: input.side,
lineNumber,
lineType: input.line.type,
content: input.line.content,
line: input.line,
};
}
export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] {
return buildNumberedDiffHunks(file).flatMap((hunk) =>
hunk.lines.map((numberedLine) => ({
key: numberedLine.key,
line: numberedLine.line,
lineNumber: numberedLine.unifiedCell?.lineNumber ?? null,
reviewTarget: numberedLine.unifiedCell ? toReviewTarget(numberedLine.unifiedCell) : null,
})),
);
}
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
const rows: SplitDiffRow[] = [];
for (const hunk of buildNumberedDiffHunks(file)) {
rows.push({
kind: "header",
content: hunk.hunkHeader,
});
let pendingRemovals: NumberedDiffCell[] = [];
let pendingAdditions: NumberedDiffCell[] = [];
const flushPendingRows = () => {
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
for (let index = 0; index < pairCount; index += 1) {
const removal = pendingRemovals[index] ?? null;
const addition = pendingAdditions[index] ?? null;
rows.push({
kind: "pair",
left: toSplitDisplayLine(removal),
right: toSplitDisplayLine(addition),
});
}
pendingRemovals = [];
pendingAdditions = [];
};
for (const numberedLine of hunk.lines) {
if (numberedLine.line.type === "header") {
continue;
}
if (numberedLine.line.type === "remove") {
if (numberedLine.oldCell) {
pendingRemovals.push(numberedLine.oldCell);
}
continue;
}
if (numberedLine.line.type === "add") {
if (numberedLine.newCell) {
pendingAdditions.push(numberedLine.newCell);
}
continue;
}
flushPendingRows();
if (numberedLine.line.type === "context") {
rows.push({
kind: "pair",
left: toSplitDisplayLine(numberedLine.oldCell),
right: toSplitDisplayLine(numberedLine.newCell),
});
}
}
flushPendingRows();
}
return rows;
}