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
+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;
}
},
};
}