feat: support creating worktrees from GitHub pull requests

This commit is contained in:
Mohamed Boudra
2026-04-18 12:08:30 +07:00
parent 82f97e942d
commit 14cc1cc83c
48 changed files with 6448 additions and 1515 deletions
+49 -1
View File
@@ -1,6 +1,6 @@
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
@@ -182,6 +182,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
let daemonProcess: ChildProcess | null = null;
let metroProcess: ChildProcess | null = null;
let paseoHome: string | null = null;
let fakeGhBinDir: string | null = null;
let relayProcess: ChildProcess | null = null;
type OfferPayload = {
@@ -191,6 +192,47 @@ type OfferPayload = {
relay: { endpoint: string };
};
async function createFakeGhBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
const ghPath = path.join(binDir, "gh");
await writeFile(
ghPath,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
}
if (args[0] === "pr" && args[1] === "list") {
console.log(JSON.stringify([
{
number: 515,
title: "Review selected start ref",
url: "https://github.com/getpaseo/paseo/pull/515",
state: "OPEN",
body: "Fixture pull request for app e2e.",
labels: [],
baseRefName: "main",
headRefName: "feature/start-from-pr"
}
]));
process.exit(0);
}
if (args[0] === "issue" && args[1] === "list") {
console.log("[]");
process.exit(0);
}
console.error("Unsupported fake gh invocation: " + args.join(" "));
process.exit(1);
`,
);
await chmod(ghPath, 0o755);
return binDir;
}
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, "");
}
@@ -281,6 +323,7 @@ export default async function globalSetup() {
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
fakeGhBinDir = await createFakeGhBin();
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
@@ -298,6 +341,10 @@ export default async function globalSetup() {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
}
};
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
@@ -482,6 +529,7 @@ export default async function globalSetup() {
cwd: serverDir,
env: {
...process.env,
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${port}`,
+45 -1
View File
@@ -153,7 +153,7 @@ export async function createWorktreeViaDaemon(
};
}
export async function clickNewWorkspaceButton(
export async function openNewWorkspaceComposer(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
@@ -168,7 +168,13 @@ export async function clickNewWorkspaceButton(
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
@@ -176,6 +182,44 @@ export async function clickNewWorkspaceButton(
await createButton.click();
}
export async function openStartingRefPicker(page: Page): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
}
export async function selectBranchInPicker(page: Page, name: string): Promise<void> {
const branchRow = page.getByTestId(`new-workspace-ref-picker-branch-${name}`);
await expect(branchRow).toBeVisible({ timeout: 30_000 });
await branchRow.click();
}
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
await expect(prRow).toBeVisible({ timeout: 30_000 });
await prRow.click();
}
export async function expectStartingRefPickerTriggerPr(
page: Page,
input: { number: number; title: string; headRef: string },
): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toContainText(`#${input.number}`);
await expect(trigger).toContainText(input.title);
await expect(trigger).not.toContainText(input.headRef);
}
export async function expectComposerGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
): Promise<void> {
const pills = page.getByTestId("composer-github-attachment-pill");
await expect(pills).toHaveCount(1);
await expect(pills.first()).toContainText(`#${input.number}`);
await expect(pills.first()).toContainText(input.title);
}
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
+56 -1
View File
@@ -5,6 +5,7 @@ import path from "node:path";
type TempRepo = {
path: string;
branchHeads: Record<string, string>;
cleanup: () => Promise<void>;
};
@@ -14,6 +15,7 @@ export const createTempGitRepo = async (
withRemote?: boolean;
paseoConfig?: Record<string, unknown>;
files?: Array<{ path: string; content: string }>;
branches?: string[];
},
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
@@ -47,19 +49,72 @@ export const createTempGitRepo = async (
}
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
const branchHeads: Record<string, string> = {};
const branches = Array.from(new Set(options?.branches ?? []));
for (const branch of branches) {
if (branch !== "main") {
execSync(`git checkout -b ${JSON.stringify(branch)} main`, {
cwd: repoPath,
stdio: "ignore",
});
}
const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`;
await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`);
execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, {
cwd: repoPath,
stdio: "ignore",
});
branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, {
cwd: repoPath,
stdio: "pipe",
})
.toString()
.trim();
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
}
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
}
return {
path: repoPath,
branchHeads,
cleanup: async () => {
await rm(repoPath, { recursive: true, force: true });
},
};
};
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
currentBranch: string;
hasAncestor: (ref: string) => boolean;
}> {
const currentBranch = execSync("git branch --show-current", {
cwd: worktreePath,
stdio: "pipe",
})
.toString()
.trim();
return {
currentBranch,
hasAncestor: (ref: string) => {
try {
execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, {
cwd: worktreePath,
stdio: "ignore",
});
return true;
} catch {
return false;
}
},
};
}
+99 -1
View File
@@ -1,3 +1,5 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
@@ -8,9 +10,15 @@ import {
clickNewWorkspaceButton,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
expectComposerGithubAttachmentPill,
expectStartingRefPickerTriggerPr,
openNewWorkspaceComposer,
openStartingRefPicker,
openProjectViaDaemon,
selectBranchInPicker,
selectGitHubPrInPicker,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
@@ -254,4 +262,94 @@ test.describe("New workspace flow", () => {
await tempRepo.cleanup();
}
});
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
branches: ["main", "dev"],
});
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
const branchInfo = await readWorktreeBranchInfo({
worktreePath: createdWorkspace.workspaceId,
});
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
} finally {
await tempRepo.cleanup();
}
});
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, 515);
await expectStartingRefPickerTriggerPr(page, {
number: 515,
title: "Review selected start ref",
headRef: "feature/start-from-pr",
});
await expectComposerGithubAttachmentPill(page, {
number: 515,
title: "Review selected start ref",
});
} finally {
await tempRepo.cleanup();
}
});
});