Files
kandev/apps/web/e2e/tests/git/fork-pr-comparison-target-helpers.ts
T
2026-09-13 21:57:54 +01:00

460 lines
16 KiB
TypeScript

import { execFileSync, spawn, type ChildProcess } from "node:child_process";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import fs from "node:fs";
import path from "node:path";
import type { BackendContext } from "../../fixtures/backend";
import { resetSeedRepositoryCheckout, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
const targetURL = "https://github.com/upstream/widget.git";
type ForkPRComparisonOptions = {
comparisonTargetAvailable?: boolean;
localUncommittedFile?: string;
};
export type ComparisonTargetAuthFixture = {
url: string;
unauthorizedRequestCount: () => number;
setAvailable: (available: boolean) => void;
close: () => Promise<void>;
};
type ActiveComparisonTargetFixture = {
taskID?: string;
fixture?: ComparisonTargetAuthFixture;
releaseBackendEnv?: () => Promise<void>;
};
const activeComparisonTargetFixtures = new Map<string, ActiveComparisonTargetFixture>();
export async function seedForkPRComparisonTask(
apiClient: ApiClient,
seedData: SeedData,
backend: BackendContext,
options: ForkPRComparisonOptions = {},
) {
const gitEnv = makeGitEnv(backend.tmpDir);
const suffix = `${process.pid}-${Date.now()}`;
const targetRemoteDir = path.join(backend.tmpDir, "repos", `upstream-${suffix}.git`);
const targetWorktreeDir = path.join(backend.tmpDir, "repos", `upstream-${suffix}`);
const localGit = new GitHelper(seedData.repositoryPath, gitEnv);
// The seed repository is shared by tests in a worker. A prior git test can
// leave it on a feature branch or with uncommitted fixture files, which
// makes the fork checkout below fail before the scenario starts.
resetSeedRepositoryCheckout(seedData, backend.tmpDir);
// A previous scenario can finish while its comparison fetch is still
// unwinding after the backend restart. Remove its deterministic remote ref
// again before this scenario creates its target.
clearComparisonTargetRefs(seedData, backend.tmpDir);
execFileSync("git", ["clone", "--bare", seedData.repositoryRemoteURL, targetRemoteDir], {
env: gitEnv,
});
execFileSync("git", ["clone", `file://${targetRemoteDir}`, targetWorktreeDir], { env: gitEnv });
const upstreamGit = new GitHelper(targetWorktreeDir, gitEnv);
upstreamGit.createFile("upstream-only.txt", "upstream target commit\n");
upstreamGit.stageAll();
upstreamGit.commit("Advance upstream target");
upstreamGit.exec("git push origin main");
execFileSync("git", ["fetch", `file://${targetRemoteDir}`, "main"], {
cwd: seedData.repositoryPath,
env: gitEnv,
});
localGit.exec("git checkout -B feature/fork FETCH_HEAD");
for (const [name, content] of [
["fork-one.txt", "fork change one\n"],
["fork-two.txt", "fork change two\n"],
["fork-three.txt", "fork change three\n"],
]) {
localGit.createFile(name, content);
}
localGit.stageAll();
const headSHA = localGit.commit("Add three fork contribution files");
if (options.localUncommittedFile) {
fs.writeFileSync(
path.join(seedData.repositoryPath, options.localUncommittedFile),
"local change remains available\n",
);
}
// agentctl fetches the credential-free provider URL. The test maps that URL
// to its disposable upstream bare repository without changing origin. The
// unavailable mode uses a local HTTP server which returns 401 until the
// test restores it, so the scenario proves authentication failure and
// recovery rather than only a missing path.
let comparisonTargetURL: string;
let comparisonTargetFixture: ComparisonTargetAuthFixture | undefined;
let comparisonExecutorProfileID: string | undefined;
if (options.comparisonTargetAvailable === false) {
comparisonTargetFixture = await startComparisonTargetAuthFixture(targetRemoteDir);
comparisonTargetURL = comparisonTargetFixture.url;
const comparisonGitConfigPath = path.join(
backend.tmpDir,
`comparison-target-${suffix}.gitconfig`,
);
execFileSync(
"git",
[
"config",
"--file",
comparisonGitConfigPath,
`url.${comparisonTargetURL}.insteadOf`,
targetURL,
],
{ env: gitEnv },
);
let releaseBackendEnv: (() => Promise<void>) | undefined;
try {
// The root comparison tracker is created when agentctl starts. Install
// the rewrite in the backend's inherited environment before the task is
// launched so that first materialization and later task Git commands use
// the same disposable target.
releaseBackendEnv = await backend.useEnv({
GIT_CONFIG_GLOBAL: comparisonGitConfigPath,
});
const { executors } = await apiClient.listExecutors();
const localExecutor = executors.find(
(candidate) => candidate.type === "local" || candidate.type === "local_pc",
);
if (!localExecutor) {
throw new Error("comparison target fixture requires a direct local executor");
}
const profile = await apiClient.createExecutorProfile(localExecutor.id, {
name: `E2E comparison target ${suffix}`,
env_vars: [
{ key: "GIT_CONFIG_COUNT", value: "1" },
...gitConfigEnvVarsForURLRewrite(comparisonTargetURL),
],
});
comparisonExecutorProfileID = profile.id;
activeComparisonTargetFixtures.set(backend.tmpDir, {
fixture: comparisonTargetFixture,
releaseBackendEnv,
});
} catch (setupError) {
try {
await releaseBackendEnv?.();
} finally {
await comparisonTargetFixture.close();
}
throw setupError;
}
} else {
comparisonTargetURL = `file://${targetRemoteDir}`;
}
if (options.comparisonTargetAvailable !== false) {
execFileSync("git", ["config", "--global", `url.${comparisonTargetURL}.insteadOf`, targetURL], {
cwd: backend.tmpDir,
env: gitEnv,
});
}
await apiClient.updateRepository(seedData.repositoryId, {
provider: "github",
provider_repo_id: "42",
provider_host: "https://github.com",
provider_owner: "contributor",
provider_name: "widget-fork",
});
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("contributor");
await apiClient.mockGitHubAddPRs([
{
number: 1701,
title: "Fork contribution",
state: "open",
head_branch: "feature/fork",
head_sha: headSHA,
base_branch: "main",
author_login: "contributor",
repo_owner: "upstream",
repo_name: "widget",
head_repo_id: 42,
head_repo_owner: "contributor",
head_repo_name: "widget-fork",
head_repo_clone_url: "https://github.com/contributor/widget-fork.git",
base_repo_id: 99,
base_repo_owner: "upstream",
base_repo_name: "widget",
base_default_branch: "main",
maintainer_can_modify: true,
html_url: "https://github.com/upstream/widget/pull/1701",
},
]);
await apiClient.mockGitHubAddPRFiles("upstream", "widget", 1701, [
{ filename: "fork-one.txt", status: "added", additions: 1, deletions: 0 },
{ filename: "fork-two.txt", status: "added", additions: 1, deletions: 0 },
{ filename: "fork-three.txt", status: "added", additions: 1, deletions: 0 },
]);
await apiClient.mockGitHubAddPRCommits("upstream", "widget", 1701, [
{
sha: headSHA,
message: "Add three fork contribution files",
author_login: "contributor",
author_date: "2026-08-19T12:00:00Z",
stats_available: true,
},
]);
const task = await apiClient.createTask(seedData.workspaceId, "Fork PR comparison target", {
description: "/e2e:simple-message",
agent_profile_id: seedData.agentProfileId,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repositories: [
{
repository_id: seedData.repositoryId,
base_branch: "main",
checkout_branch: "feature/fork",
},
],
executor_profile_id: comparisonExecutorProfileID,
});
await apiClient.associateGitHubTaskPR({
workspace_id: seedData.workspaceId,
task_id: task.id,
repository_id: seedData.repositoryId,
pr_url: "https://github.com/upstream/widget/pull/1701",
});
await apiClient.launchSession({
task_id: task.id,
agent_profile_id: seedData.agentProfileId,
executor_profile_id: comparisonExecutorProfileID,
workflow_step_id: seedData.startStepId,
prompt: "/e2e:simple-message",
});
const active = activeComparisonTargetFixtures.get(backend.tmpDir);
activeComparisonTargetFixtures.set(backend.tmpDir, {
...active,
taskID: task.id,
});
return { task, headSHA, comparisonTargetFixture };
}
function gitConfigEnvVarsForURLRewrite(comparisonTargetURL: string) {
return [
{ key: "GIT_CONFIG_KEY_0", value: `url.${comparisonTargetURL}.insteadOf` },
{ key: "GIT_CONFIG_VALUE_0", value: targetURL },
];
}
export async function resetForkPRComparisonRepository(
seedData: SeedData,
backend: BackendContext,
apiClient: ApiClient,
) {
const active = activeComparisonTargetFixtures.get(backend.tmpDir);
activeComparisonTargetFixtures.delete(backend.tmpDir);
await active?.releaseBackendEnv?.();
await active?.fixture?.close();
await clearTaskComparisonTargetRefs(active?.taskID, apiClient, backend.tmpDir);
resetSeedRepositoryCheckout(seedData, backend.tmpDir);
clearComparisonTargetRefs(seedData, backend.tmpDir);
}
async function clearTaskComparisonTargetRefs(
taskID: string | undefined,
apiClient: ApiClient,
tmpDir: string,
) {
if (!taskID) return;
const environment = await apiClient.getTaskEnvironment(taskID);
const repositoryPaths = new Set<string>();
if (environment?.workspace_path) repositoryPaths.add(environment.workspace_path);
if (environment?.worktree_path) repositoryPaths.add(environment.worktree_path);
for (const repository of environment?.repos ?? []) {
if (repository.worktree_path) repositoryPaths.add(repository.worktree_path);
}
for (const repositoryPath of repositoryPaths) {
clearComparisonTargetRefsAtPath(repositoryPath, tmpDir);
}
}
function clearComparisonTargetRefs(seedData: SeedData, tmpDir: string) {
clearComparisonTargetRefsAtPath(seedData.repositoryPath, tmpDir);
}
function clearComparisonTargetRefsAtPath(repositoryPath: string, tmpDir: string) {
if (!fs.existsSync(repositoryPath)) return;
const env = makeGitEnv(tmpDir);
let output: string;
try {
output = execFileSync(
"git",
["-C", repositoryPath, "for-each-ref", "--format=%(refname)", "refs/remotes/compare-*"],
{ env },
)
.toString()
.trim();
} catch {
return;
}
for (const ref of output ? output.split(/\r?\n/) : []) {
execFileSync("git", ["-C", repositoryPath, "update-ref", "-d", ref], { env });
}
}
async function startComparisonTargetAuthFixture(
repoDir: string,
): Promise<ComparisonTargetAuthFixture> {
const repoName = path.basename(repoDir);
const repoPath = `/${repoName}`;
let available = false;
let unauthorizedRequests = 0;
const children = new Set<ChildProcess>();
const requestContext: ComparisonTargetRequestContext = {
repoDir,
repoPath,
isAvailable: () => available,
recordUnauthorized: () => {
unauthorizedRequests += 1;
},
children,
};
const server = createServer((request, response) => {
void serveComparisonTargetRequest(request, response, requestContext);
});
const port = await listenComparisonTargetServer(server);
return {
url: `http://127.0.0.1:${port}${repoPath}`,
unauthorizedRequestCount: () => unauthorizedRequests,
setAvailable: (next) => {
available = next;
},
close: async () => {
for (const child of children) child.kill();
await closeComparisonTargetServer(server);
},
};
}
type ComparisonTargetRequestContext = {
repoDir: string;
repoPath: string;
isAvailable: () => boolean;
recordUnauthorized: () => void;
children: Set<ChildProcess>;
};
async function serveComparisonTargetRequest(
request: IncomingMessage,
response: ServerResponse,
context: ComparisonTargetRequestContext,
): Promise<void> {
const { repoDir, repoPath, isAvailable, recordUnauthorized, children } = context;
const parsed = new URL(request.url ?? "/", "http://comparison-target");
const pathname = decodeURIComponent(parsed.pathname);
const isAdvertiseRequest = pathname === `${repoPath}/info/refs`;
const isUploadRequest = pathname === `${repoPath}/git-upload-pack`;
if (
(!isAdvertiseRequest && !isUploadRequest) ||
(isAdvertiseRequest && request.method !== "GET") ||
(isUploadRequest && request.method !== "POST")
) {
request.resume();
response.writeHead(404).end();
return;
}
const available = isAvailable();
if (!available) {
recordUnauthorized();
request.resume();
response.writeHead(401, { "www-authenticate": 'Basic realm="comparison-target"' }).end();
return;
}
const child = spawn("git", ["http-backend"], {
env: {
...process.env,
GIT_HTTP_EXPORT_ALL: "1",
GIT_PROJECT_ROOT: path.dirname(repoDir),
PATH_INFO: pathname,
QUERY_STRING: parsed.search.slice(1),
REQUEST_METHOD: request.method ?? "GET",
CONTENT_TYPE: String(request.headers["content-type"] ?? ""),
CONTENT_LENGTH: String(request.headers["content-length"] ?? ""),
...(request.headers["git-protocol"]
? { HTTP_GIT_PROTOCOL: String(request.headers["git-protocol"]) }
: {}),
},
});
children.add(child);
const output: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => output.push(chunk));
child.stderr.resume();
child.stdin.on("error", () => undefined);
child.stdout.on("error", () => undefined);
child.once("error", () => {
children.delete(child);
response.writeHead(500).end();
});
child.once("close", (code) => {
children.delete(child);
if (code !== 0) {
response.writeHead(500).end();
return;
}
const cgiResponse = Buffer.concat(output);
const separator = cgiResponse.indexOf(Buffer.from("\r\n\r\n"));
const separatorLength = separator >= 0 ? 4 : 2;
const headerEnd = separator >= 0 ? separator : cgiResponse.indexOf(Buffer.from("\n\n"));
if (headerEnd < 0) {
response.writeHead(500).end();
return;
}
const headers = cgiResponse.subarray(0, headerEnd).toString("utf8").split(/\r?\n/);
let status = 200;
const responseHeaders: Record<string, string> = {};
for (const header of headers) {
const separatorIndex = header.indexOf(":");
if (separatorIndex < 0) continue;
const name = header.slice(0, separatorIndex);
const value = header.slice(separatorIndex + 1).trim();
if (name.toLowerCase() === "status") {
status = Number.parseInt(value, 10) || 500;
} else {
responseHeaders[name] = value;
}
}
response.writeHead(status, responseHeaders);
response.end(cgiResponse.subarray(headerEnd + separatorLength));
});
if (isAdvertiseRequest) child.stdin.end();
else request.pipe(child.stdin);
}
function listenComparisonTargetServer(server: Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("comparison target fixture did not receive a TCP port"));
return;
}
resolve(address.port);
});
});
}
function closeComparisonTargetServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
});
}