#!/usr/bin/env bun /** * End-to-end check that the merge queue hands a conflicting pull to an AI fix and re-enqueues it after * CI passes on the fix commit, on both paths: * * - propose (default): the run stops at a proposal without pushing; `POST .../ai-fix/apply` commits it, * a repeated apply is a no-op, and only then does the pull rejoin the queue. * - auto (repo opt-in): the confident fix lands by itself, as before. * * Needs a dev server with MERGEFIX_PROVIDER=mock; takes several minutes because the Workflow polls CI * every two minutes. Queues the pull through `PUT /pulls/:n/merge_queue`. * * D1_CWD=$PWD bun scripts/e2e-mergefix-queue.ts */ import { api, assert, d1, fixStatus, poll, prepareConflict } from "./mergefix-harness"; import type { Fix } from "./mergefix-harness"; const REQUEUE_TIMEOUT_MS = 15 * 60 * 1000; const FIX_TIMEOUT_MS = 5 * 60 * 1000; const user = await api<{ login: string }>("GET", "/user"); const line = (middle: string) => `header\none\n${middle}\nthree\nfooter\n`; async function queuedConflict(mode: "propose" | "auto") { const name = `mergefix-queue-${mode}-${Date.now().toString(36)}`; const prepared = await prepareConflict(user.login, name, [ { path: "app.txt", base: line("two"), head: line("two (head wording)"), main: line("two (main wording)") }, ]); const repoId = String(d1(`SELECT id FROM repos WHERE name = '${name}'`)[0]?.id); d1( `INSERT INTO repo_mergefix_settings (repo_id, queue_enabled, allow_private, auto_apply, min_confidence, min_margin, updated_at) VALUES ('${repoId}', 1, 0, ${mode === "auto" ? 1 : 0}, 0.8, 0.3, ${Date.now()})`, ); await api( "PUT", `/repos/${user.login}/${name}/pulls/${prepared.pull.number}/merge_queue`, { method: "merge", expected_head: prepared.before }, [200, 202, 204], ); const settled = await poll(`the queue-triggered ${mode} run to settle`, FIX_TIMEOUT_MS, async () => { const fix = await fixStatus(user.login, name, prepared.pull.number); return fix && !["queued", "running", "applying"].includes(fix.status) ? fix : null; }); assert(settled.mode === mode, `expected mode ${mode}, got ${settled.mode}`); return { name, repoId, prepared, settled }; } async function expectRequeued(name: string, repoId: string, number: number) { const requeued = await poll("the pull to rejoin the queue", REQUEUE_TIMEOUT_MS, async () => { const fix = await fixStatus(user.login, name, number); return fix?.requeue && fix.requeue.status !== "waiting" ? fix.requeue : null; }); assert(requeued.status === "enqueued", `expected enqueued, got ${requeued.status}: ${requeued.error}`); const status = await api<{ merged: boolean; queue: { state: string } | null }>( "GET", `/repos/${user.login}/${name}/pulls/${number}/status`, ); const state = status.queue?.state ?? (status.merged ? "merged" : "none"); assert(["queued", "testing", "merged"].includes(state), `queue entry state ${state} (repo ${repoId})`); return state; } type Applied = { id: string; status: string; commit_sha: string; requeue: boolean; already_applied: boolean }; const proposal = await queuedConflict("propose"); try { const { name, repoId, prepared, settled } = proposal; const number = prepared.pull.number; assert(settled.status === "suggested", `expected a proposal, got ${settled.status}: ${settled.error}`); assert(prepared.remoteHead() === prepared.before, "a proposal must not push to the head branch"); assert(settled.requeue === null, "a proposal must not re-enqueue before it is applied"); const [file] = settled.files as Array<{ path: string; diff: string | null; risk: string; reasons: string[] }>; assert(file?.diff?.includes("+"), "the proposal carries a per-file diff"); console.log(`proposal ready for ${file.path} (risk ${file.risk}); applying`); const applyPath = `/repos/${user.login}/${name}/pulls/${number}/ai-fix/apply`; const applied = await api("POST", applyPath, { files: [file.path] }, [200]); assert(applied.status === "applied" && !applied.already_applied, "first apply commits the proposal"); assert(applied.requeue, "a queue-triggered proposal re-enqueues once applied"); assert(prepared.remoteHead() === applied.commit_sha, "head branch moved to the applied proposal"); const again = await api("POST", applyPath, {}, [200]); assert(again.already_applied && again.commit_sha === applied.commit_sha, "repeated apply does not push again"); const state = await expectRequeued(name, repoId, number); console.log(`ok: propose -> apply ${applied.commit_sha.slice(0, 7)}, re-enqueued (queue entry: ${state})`); } finally { proposal.prepared.cleanup(); } const auto = await queuedConflict("auto"); try { const { name, repoId, prepared, settled } = auto; const applied: Fix = settled; assert(applied.status === "applied", `expected applied, got ${applied.status}: ${applied.error}`); assert(prepared.remoteHead() === applied.commit_sha, "head branch moved to the fix commit"); console.log(`auto fix ${applied.commit_sha?.slice(0, 7)} applied; waiting for CI and re-enqueue`); const state = await expectRequeued(name, repoId, prepared.pull.number); console.log(`ok: auto fix re-enqueued after green CI (queue entry: ${state})`); } finally { auto.prepared.cleanup(); }