fix: support fork PR links on upstream tasks (#3885)
This commit is contained in:
@@ -4,6 +4,12 @@ import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { createEmptyRemoteRepository } from "../../helpers/empty-remote-repository";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
import {
|
||||
cleanupPRLinkForkLaunchFixture,
|
||||
createPRLinkForkLaunchFixture,
|
||||
expectForkPRLaunchState,
|
||||
} from "./pr-link-fork-launch-helpers";
|
||||
|
||||
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
|
||||
useRegularMode();
|
||||
@@ -174,6 +180,72 @@ test.describe("Task creation from GitHub URL", () => {
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("starts a target-attached fork PR from its URL", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const fixture = await createPRLinkForkLaunchFixture(
|
||||
apiClient,
|
||||
seedData.workspaceId,
|
||||
backend.tmpDir,
|
||||
);
|
||||
let taskId: string | undefined;
|
||||
|
||||
try {
|
||||
const { executors } = await apiClient.listExecutors();
|
||||
const worktreeExec = executors.find((executor) => executor.type === "worktree");
|
||||
if (!worktreeExec?.profiles?.[0]) {
|
||||
test.skip(true, "No worktree executor profile available");
|
||||
return;
|
||||
}
|
||||
|
||||
const taskTitle = `Fork PR launch ${fixture.repositoryName}`;
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await openRemoteAndPasteURL(testPage, fixture.prURL);
|
||||
await expect(testPage.getByTestId("remote-branch-chip-trigger").first()).toContainText(
|
||||
fixture.headBranch,
|
||||
);
|
||||
await testPage.getByTestId("task-title-input").fill(taskTitle);
|
||||
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
|
||||
|
||||
const startButton = testPage.getByTestId("submit-start-agent");
|
||||
await expect(startButton).toBeEnabled();
|
||||
await testPage.getByTestId("executor-profile-selector").click();
|
||||
await testPage.getByRole("option", { name: /Worktree/i }).click();
|
||||
const createdTaskResponse = waitForHttp(testPage, "POST", /\/api\/v1\/tasks$/);
|
||||
await startButton.click();
|
||||
const response = await createdTaskResponse;
|
||||
const responseBody = await response.text();
|
||||
expect(response.status(), responseBody).toBe(200);
|
||||
const created = JSON.parse(responseBody) as { id: string };
|
||||
taskId = created.id;
|
||||
const requestBody = response.request().postDataJSON() as {
|
||||
repositories?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(requestBody.repositories?.[0]).not.toHaveProperty("remote_contribution");
|
||||
expect(requestBody.repositories?.[0]).not.toHaveProperty("comparison_target");
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${taskId}$`));
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible();
|
||||
await expect(session.idleInput()).toBeVisible();
|
||||
|
||||
await expectForkPRLaunchState(testPage, session, apiClient, fixture, taskId);
|
||||
await expect(session.prTopbarButton()).toContainText("#3879", { timeout: 15_000 });
|
||||
} finally {
|
||||
await cleanupPRLinkForkLaunchFixture(apiClient, fixture, taskId);
|
||||
}
|
||||
});
|
||||
|
||||
// Three tests previously asserted the top-level `github-url-error` testid
|
||||
// surfaced on invalid URL, nonexistent repo, and "clears on valid URL".
|
||||
// After Task 5/8 the URL input moved into a per-chip popover and no
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
import { switchToTerminalPanel, waitForShellReady } from "../terminal/mobile-terminal-helpers";
|
||||
import {
|
||||
cleanupPRLinkForkLaunchFixture,
|
||||
createPRLinkForkLaunchFixture,
|
||||
expectForkPRLaunchState,
|
||||
} from "./pr-link-fork-launch-helpers";
|
||||
|
||||
function expectedRemoteTitle(title: string): string {
|
||||
const characters = Array.from(title);
|
||||
@@ -96,6 +104,80 @@ test.describe("Create task Remote repo picker on mobile", () => {
|
||||
await expectNoDocumentHorizontalOverflow(testPage);
|
||||
});
|
||||
|
||||
test("starts a target-attached fork PR from its URL", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const fixture = await createPRLinkForkLaunchFixture(
|
||||
apiClient,
|
||||
seedData.workspaceId,
|
||||
backend.tmpDir,
|
||||
);
|
||||
let taskId: string | undefined;
|
||||
|
||||
try {
|
||||
const { executors } = await apiClient.listExecutors();
|
||||
const worktreeExec = executors.find((executor) => executor.type === "worktree");
|
||||
if (!worktreeExec?.profiles?.[0]) {
|
||||
test.skip(true, "No worktree executor profile available");
|
||||
return;
|
||||
}
|
||||
|
||||
const taskTitle = `Phone fork PR ${fixture.repositoryName}`;
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
await mobile.mobileFab.tap();
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await testPage.getByTestId("source-mode-remote").tap();
|
||||
await testPage.getByTestId("remote-repo-chip-trigger").first().tap();
|
||||
const urlInput = testPage.getByTestId("remote-repo-input");
|
||||
await expect(urlInput).toBeVisible();
|
||||
await urlInput.fill(fixture.prURL);
|
||||
await urlInput.press("Enter");
|
||||
await expect(testPage.getByTestId("remote-branch-chip-trigger").first()).toContainText(
|
||||
fixture.headBranch,
|
||||
);
|
||||
await testPage.getByTestId("task-title-input").fill(taskTitle);
|
||||
await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
|
||||
|
||||
const startButton = testPage.getByTestId("submit-start-agent");
|
||||
await expect(startButton).toBeEnabled();
|
||||
await testPage.getByTestId("executor-profile-selector").tap();
|
||||
await testPage.getByRole("option", { name: /Worktree/i }).tap();
|
||||
const createdTaskResponse = waitForHttp(testPage, "POST", /\/api\/v1\/tasks$/);
|
||||
await startButton.tap();
|
||||
const response = await createdTaskResponse;
|
||||
const responseBody = await response.text();
|
||||
expect(response.status(), responseBody).toBe(200);
|
||||
const created = JSON.parse(responseBody) as { id: string };
|
||||
taskId = created.id;
|
||||
const requestBody = response.request().postDataJSON() as {
|
||||
repositories?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(requestBody.repositories?.[0]).not.toHaveProperty("remote_contribution");
|
||||
expect(requestBody.repositories?.[0]).not.toHaveProperty("comparison_target");
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(mobile.taskCard(taskId)).toBeVisible({ timeout: 15_000 });
|
||||
await mobile.taskCard(taskId).tap();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${taskId}$`));
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible();
|
||||
await expect(session.idleInput()).toBeVisible();
|
||||
|
||||
await switchToTerminalPanel(testPage);
|
||||
await waitForShellReady(testPage);
|
||||
await expectForkPRLaunchState(testPage, session, apiClient, fixture, taskId);
|
||||
} finally {
|
||||
await cleanupPRLinkForkLaunchFixture(apiClient, fixture, taskId);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a failed URL row touch-usable and retries its branch resolution", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import {
|
||||
createEmptyRemoteRepository,
|
||||
removeTestRepository,
|
||||
} from "../../helpers/empty-remote-repository";
|
||||
import { GitHelper } from "../../helpers/git-helper";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const PR_NUMBER = 3879;
|
||||
const HEAD_BRANCH = "feature/fork-pr-launch";
|
||||
|
||||
export async function expectTerminalCommit(page: Page, commitOID: string): Promise<void> {
|
||||
const readUnwrappedTerminal = async () =>
|
||||
page
|
||||
.locator('[data-testid="terminal-panel"]:visible')
|
||||
.first()
|
||||
.locator(".xterm")
|
||||
.evaluate((xtermElement) => {
|
||||
const container = xtermElement.parentElement as HTMLElement & {
|
||||
__xtermReadBuffer?: () => string;
|
||||
};
|
||||
return (container.__xtermReadBuffer?.() ?? "").replace(/\s+/gu, "");
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(readUnwrappedTerminal, {
|
||||
timeout: 10_000,
|
||||
message: `Expected terminal to contain commit ${commitOID} without line wrapping`,
|
||||
})
|
||||
.toContain(commitOID);
|
||||
}
|
||||
|
||||
export type PRLinkForkLaunchFixture = {
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
upstreamOwner: string;
|
||||
upstreamRepository: string;
|
||||
forkOwner: string;
|
||||
forkRepository: string;
|
||||
prURL: string;
|
||||
headBranch: string;
|
||||
headOID: string;
|
||||
targetOID: string;
|
||||
forkCleanup: () => void;
|
||||
upstreamCleanup: () => void;
|
||||
};
|
||||
|
||||
export async function expectForkPRLaunchState(
|
||||
page: Page,
|
||||
session: SessionPage,
|
||||
apiClient: ApiClient,
|
||||
fixture: PRLinkForkLaunchFixture,
|
||||
taskId: string,
|
||||
): Promise<void> {
|
||||
await expect(session.terminal).toBeVisible();
|
||||
await session.typeInTerminal("git branch --show-current");
|
||||
await session.expectTerminalHasText(fixture.headBranch);
|
||||
await session.typeInTerminal("git rev-parse HEAD");
|
||||
await expectTerminalCommit(page, fixture.headOID);
|
||||
await session.typeInTerminal("git rev-parse refs/remotes/origin/main");
|
||||
await expectTerminalCommit(page, fixture.targetOID);
|
||||
|
||||
await page.reload();
|
||||
await session.waitForLoad();
|
||||
const task = await apiClient.getTask(taskId);
|
||||
expect(task.repositories?.[0]?.checkout_branch).toBe(fixture.headBranch);
|
||||
expect(task.repositories?.[0]?.base_branch).toBe("main");
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(taskId)).status_summary?.pull_request?.number, {
|
||||
timeout: 15_000,
|
||||
message: "waiting for the persisted PR summary to reach the task list",
|
||||
})
|
||||
.toBe(PR_NUMBER);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await apiClient.listTaskPRs(taskId)).map((pr) => ({
|
||||
owner: pr.owner,
|
||||
repo: pr.repo,
|
||||
pr_number: pr.pr_number,
|
||||
head_branch: pr.head_branch,
|
||||
})),
|
||||
)
|
||||
.toEqual([
|
||||
{
|
||||
owner: fixture.upstreamOwner,
|
||||
repo: fixture.upstreamRepository,
|
||||
pr_number: PR_NUMBER,
|
||||
head_branch: fixture.headBranch,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export async function createPRLinkForkLaunchFixture(
|
||||
apiClient: ApiClient,
|
||||
workspaceId: string,
|
||||
tmpDir: string,
|
||||
): Promise<PRLinkForkLaunchFixture> {
|
||||
const suffix = randomUUID().replaceAll("-", "").slice(0, 10);
|
||||
const upstreamOwner = `e2e-upstream-${suffix}`;
|
||||
const upstreamRepository = `pr-link-upstream-${suffix}`;
|
||||
const forkOwner = `e2e-fork-${suffix}`;
|
||||
const forkRepository = `pr-link-fork-${suffix}`;
|
||||
const upstream = createEmptyRemoteRepository(tmpDir, `pr-link-upstream-${suffix}`);
|
||||
const fork = createEmptyRemoteRepository(tmpDir, `pr-link-fork-${suffix}`);
|
||||
let repositoryId = "";
|
||||
|
||||
try {
|
||||
const upstreamGit = new GitHelper(upstream.localPath, upstream.gitEnv);
|
||||
upstreamGit.createFile("base.txt", "upstream target main\n");
|
||||
upstreamGit.stageAll();
|
||||
const targetOID = upstreamGit.commit("upstream target main");
|
||||
upstreamGit.exec("git push origin main");
|
||||
|
||||
const forkGit = new GitHelper(fork.localPath, fork.gitEnv);
|
||||
forkGit.createFile("base.txt", "fork main\n");
|
||||
forkGit.stageAll();
|
||||
forkGit.commit("fork main");
|
||||
forkGit.exec("git push origin main");
|
||||
forkGit.exec(`git checkout -b ${HEAD_BRANCH}`);
|
||||
forkGit.createFile("pr.txt", "fork pull request head\n");
|
||||
forkGit.stageAll();
|
||||
const headOID = forkGit.commit("fork pull request head");
|
||||
forkGit.exec(`git push origin ${HEAD_BRANCH}`);
|
||||
forkGit.exec(`git push "${upstream.remoteURL}" HEAD:refs/pull/${PR_NUMBER}/head`);
|
||||
|
||||
repositoryId = (
|
||||
await apiClient.createRepository(workspaceId, upstream.localPath, "main", {
|
||||
name: `${upstreamOwner}/${upstreamRepository}`,
|
||||
provider: "github",
|
||||
provider_owner: upstreamOwner,
|
||||
provider_name: upstreamRepository,
|
||||
pull_before_worktree: false,
|
||||
})
|
||||
).id;
|
||||
await apiClient.mockGitHubAddBranches(upstreamOwner, upstreamRepository, [
|
||||
{ name: "main" },
|
||||
{ name: HEAD_BRANCH },
|
||||
]);
|
||||
await apiClient.mockGitHubAddPRs([
|
||||
{
|
||||
number: PR_NUMBER,
|
||||
title: "Fork PR task launch",
|
||||
state: "open",
|
||||
head_branch: HEAD_BRANCH,
|
||||
head_sha: headOID,
|
||||
head_repo_id: 387901,
|
||||
head_repo_owner: forkOwner,
|
||||
head_repo_name: forkRepository,
|
||||
head_repo_clone_url: `https://github.com/${forkOwner}/${forkRepository}.git`,
|
||||
base_branch: "main",
|
||||
base_repo_id: 387902,
|
||||
base_repo_owner: upstreamOwner,
|
||||
base_repo_name: upstreamRepository,
|
||||
base_default_branch: "main",
|
||||
author_login: "e2e-fork-author",
|
||||
repo_owner: upstreamOwner,
|
||||
repo_name: upstreamRepository,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
repositoryId,
|
||||
repositoryName: `${upstreamOwner}/${upstreamRepository}`,
|
||||
upstreamOwner,
|
||||
upstreamRepository,
|
||||
forkOwner,
|
||||
forkRepository,
|
||||
prURL: `https://github.com/${upstreamOwner}/${upstreamRepository}/pull/${PR_NUMBER}`,
|
||||
headBranch: HEAD_BRANCH,
|
||||
headOID,
|
||||
targetOID,
|
||||
forkCleanup: fork.cleanup,
|
||||
upstreamCleanup: upstream.cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
if (repositoryId) await removeTestRepository(apiClient, repositoryId).catch(() => undefined);
|
||||
fork.cleanup();
|
||||
upstream.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupPRLinkForkLaunchFixture(
|
||||
apiClient: ApiClient,
|
||||
fixture: PRLinkForkLaunchFixture,
|
||||
taskId?: string,
|
||||
): Promise<void> {
|
||||
let cleanupError: unknown;
|
||||
if (taskId) {
|
||||
try {
|
||||
await apiClient.deleteTask(taskId);
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await removeTestRepository(apiClient, fixture.repositoryId);
|
||||
} catch (error) {
|
||||
cleanupError ??= error;
|
||||
}
|
||||
for (const cleanup of [fixture.forkCleanup, fixture.upstreamCleanup]) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
cleanupError ??= error;
|
||||
}
|
||||
}
|
||||
if (cleanupError) throw cleanupError;
|
||||
}
|
||||
Reference in New Issue
Block a user