/** * Shared harness for the merge-fix e2e and eval scripts: builds a repo with a conflicting pull request over * git as the GITFOR_TOKEN user, and runs a fix. Tokens live in the account's Durable Object, so create one in * the dev server's settings first. D1_CWD points at the checkout whose dev server is under test (its * `.wrangler` state holds the D1 directory). */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; export const BASE_URL = (process.env.BASE_URL ?? "http://localhost:3015").replace(/\/$/, ""); const POLL_MS = 3000; const D1_CWD = process.env.D1_CWD ?? process.cwd(); export const FIX_TIMEOUT_MS = 300_000; export const CI_TIMEOUT_MS = 60_000; /** A merge commit lists itself plus two parents in `rev-list --parents`. */ export const MERGE_PARENT_FIELDS = 3; /** Two generations and one Clef decision for the single hunk. */ export const EXPECTED_MIN_CALLS = 3; /** * Git runs with no system or global config, so credential helpers (osxkeychain) never see the token in * the push URL and never prompt. */ const GIT_ENV = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0" }; export function sh(cmd: string[], cwd?: string): string { const result = Bun.spawnSync(cmd, { cwd, stdout: "pipe", stderr: "pipe", env: GIT_ENV }); if (result.exitCode !== 0) throw new Error(`${cmd.join(" ")} failed: ${result.stderr.toString()}`); return result.stdout.toString(); } export function d1(sql: string): Array> { const out = sh(["bunx", "wrangler", "d1", "execute", "gitfor", "--local", "--json", "--command", sql], D1_CWD); return (JSON.parse(out) as Array<{ results: Array> }>)[0]?.results ?? []; } function requiredToken(): string { const token = process.env.GITFOR_TOKEN; if (!token) throw new Error("Set GITFOR_TOKEN to a classic token (repo scope) of a user on the dev server"); return token; } export const TOKEN = requiredToken(); export async function api( method: string, path: string, body?: unknown, expected: number[] = [200, 201, 202], ): Promise { const response = await fetch(`${BASE_URL}/api/v1${path}`, { method, headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await response.text(); if (!expected.includes(response.status)) throw new Error(`${method} ${path} -> ${response.status}: ${text}`); return (text ? JSON.parse(text) : null) as T; } export function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(`Assertion failed: ${message}`); } export async function poll(what: string, timeoutMs: number, probe: () => Promise): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const value = await probe(); if (value !== null) return value; await Bun.sleep(POLL_MS); } throw new Error(`Timed out waiting for ${what}`); } export type Fix = { status: string; mode: string; stale: boolean; error: string | null; commit_sha: string | null; files: Array>; requeue: { status: string; error: string | null } | null; }; export type ConflictFile = { path: string; base: string; head: string; main: string }; /** Creates a repo whose `feature` branch conflicts with `main`, opens a pull request and runs an AI fix. */ export async function prepareConflict( login: string, name: string, files: ConflictFile[], pr: { title: string; body: string } = { title: "Change line two", body: "Updates the second line." }, ) { const repo = await api<{ name: string }>("POST", "/repos", { name }); const work = mkdtempSync(join(tmpdir(), "mergefix-e2e-")); const url = new URL(`${BASE_URL}/${login}/${repo.name}.git`); url.username = "x"; url.password = TOKEN; const write = (path: string, text: string) => { mkdirSync(dirname(join(work, path)), { recursive: true }); writeFileSync(join(work, path), text); }; try { const git = (...args: string[]) => sh(["git", "-c", "credential.helper=", "-c", "user.name=e2e", "-c", "user.email=e2e@example.com", ...args], work); git("init", "-q", "-b", "main"); write( ".gitfor/workflows/ci.yml", "name: ci\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n", ); for (const file of files) write(file.path, file.base); git("add", "."); git("commit", "-qm", "base"); git("checkout", "-q", "-b", "feature"); for (const file of files) write(file.path, file.head); git("commit", "-qam", "head change"); git("checkout", "-q", "main"); for (const file of files) write(file.path, file.main); git("commit", "-qam", "main change"); git("push", "-q", url.toString(), "main", "feature"); const pull = await api<{ number: number }>("POST", `/repos/${login}/${name}/pulls`, { ...pr, head: "feature", base: "main", }); const before = git("rev-parse", "feature").trim(); const remoteHead = () => git("ls-remote", url.toString(), "refs/heads/feature").split("\t")[0]; const cleanup = () => rmSync(work, { recursive: true, force: true }); return { repo: name, login, pull, before, url: url.toString(), git, remoteHead, cleanup }; } catch (error) { rmSync(work, { recursive: true, force: true }); throw error; } } export function fixStatus(login: string, name: string, number: number) { return api("GET", `/repos/${login}/${name}/pulls/${number}/ai-fix`, undefined, [200]).catch((error: unknown) => { if (error instanceof Error && /-> (429|404)/.test(error.message)) return null; throw error; }); } /** Creates the conflicting pull request, then runs an AI fix on it and waits for the run to settle. */ export async function scenario( login: string, name: string, files: ConflictFile[], pr?: { title: string; body: string }, mode: "propose" | "auto" = "auto", ) { const prepared = await prepareConflict(login, name, files, pr); try { await api("POST", `/repos/${login}/${name}/pulls/${prepared.pull.number}/ai-fix`, { mode }); const fix = await poll("the AI fix", FIX_TIMEOUT_MS, async () => { const current = await fixStatus(login, name, prepared.pull.number); return !current || ["queued", "running", "applying"].includes(current.status) ? null : current; }); return { ...prepared, fix }; } catch (error) { prepared.cleanup(); throw error; } }