fix: support fork PR links on upstream tasks (#3885)

This commit is contained in:
Carlos Florêncio
2026-09-23 17:59:26 +01:00
committed by GitHub
parent 4dbeb72563
commit 8e40ba03e6
20 changed files with 1707 additions and 22 deletions
@@ -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;
}