import { env } from "cloudflare:workers"; import { repoDb } from "../scoped-db"; import { hasPermission, repoPermission } from "../repos"; import { badRequest, conflict, notFoundError } from "../errors"; import { currentBaseSha, loadPullRepos, requireHeadPush, updateHeadBranch } from "../pull-merge"; import { MergeError } from "../pulls"; import type { PullRow } from "../pulls"; import type { FixPlan } from "./plan"; import { belowThreshold, isStale, selectResolutions } from "./proposal"; import { abandonRequeue } from "./requeue"; import { latestRuns, loadRun, updateRun } from "./store"; import type { RunRow } from "./store"; import type { Viewer } from "~/lib/types"; const STALE_MESSAGE = "The head or base branch moved since this proposal; run the fix again"; export type ApplyOutcome = { runId: string; commitSha: string; requeue: boolean; alreadyApplied: boolean }; function applied(runRow: RunRow, alreadyApplied: boolean): ApplyOutcome { return { runId: runRow.id, commitSha: runRow.commit_sha ?? "", requeue: runRow.requeue_status !== null, alreadyApplied, }; } async function markStale(runId: string): Promise { await updateRun(runId, ["suggested"], { stale: 1, error: STALE_MESSAGE }); throw conflict(STALE_MESSAGE); } /** Hands a claimed proposal back to review without re-sending the "suggested" notification. */ async function releaseClaim(repoId: string, runId: string, error: string | null): Promise { await repoDb(repoId).run( "UPDATE mergefix_runs SET status = 'suggested', error = ?, applied_by = NULL, applied_files = NULL, updated_at = ? WHERE id = ? AND status = 'applying'", error, Date.now(), runId, ); } /** * Commits the accepted files of the latest proposal to the head branch as `viewer`. The suggested -> applying * compare-and-set makes concurrent or repeated calls push at most once; an already applied run is returned as is. */ export async function applyProposal( pull: PullRow, viewer: Viewer, paths?: string[], confirmLowConfidence = false, ): Promise { const [runRow] = await latestRuns(pull, 1); if (!runRow) throw notFoundError("No AI fix has run for this pull request"); if (runRow.status === "applied" && runRow.commit_sha) return applied(runRow, true); if (runRow.status === "applying") throw conflict("This fix is already being applied"); if (runRow.status !== "suggested" || !runRow.result_json) throw badRequest("There is no proposal to apply"); if (runRow.stale === 1) throw conflict(STALE_MESSAGE); if (pull.state !== "open") throw badRequest("Pull request is not open"); const { baseRepo, headRepo } = await loadPullRepos(pull); await requireHeadPush(headRepo, pull.head_ref, viewer); const plan = JSON.parse(runRow.result_json) as FixPlan; const selection = selectResolutions(plan, paths); if (!selection.ok) throw badRequest(selection.error); const unsure = belowThreshold( plan, selection.resolutions.map((resolution) => resolution.path), ); if (unsure.length > 0 && !confirmLowConfidence) { throw badRequest(`Confirm applying resolutions below the confidence threshold: ${unsure.join(", ")}`); } if (isStale(runRow, { headSha: pull.head_sha, baseSha: await currentBaseSha(baseRepo, pull.base_ref) })) { return markStale(runRow.id); } const claimed = await updateRun(runRow.id, ["suggested"], { status: "applying", error: null, applied_by: viewer.id, applied_files: JSON.stringify(selection.resolutions.map((resolution) => resolution.path)), }); if (!claimed) { const latest = await loadRun(runRow.id); if (latest?.status === "applied" && latest.commit_sha) return applied(latest, true); throw conflict("This fix is already being applied"); } let outcome: Awaited>; try { outcome = await updateHeadBranch(pull, viewer, selection.resolutions, runRow.base_sha ?? undefined, [ `X-Gitfor-MergeFix: ${runRow.id}`, "Co-authored-by: gitfor MergeFix ", ]); } catch (error) { await releaseClaim(runRow.repo_id, runRow.id, null); if (error instanceof MergeError && (await currentBaseSha(baseRepo, pull.base_ref)) !== runRow.base_sha) { return markStale(runRow.id); } throw error instanceof MergeError ? conflict(error.message) : error; } if (outcome.conflicts) { const remaining = outcome.conflicts.map((entry) => entry.path).join(", "); await releaseClaim(runRow.repo_id, runRow.id, null); throw conflict(`These files still need a resolution: ${remaining}`); } // Re-enqueueing merges with base authority, so only an applier who could queue the pull themselves gets it (CI-P11). const requeue = runRow.trigger === "queue" && runRow.queue_method !== null && hasPermission(await repoPermission(baseRepo, viewer), "write"); await updateRun(runRow.id, ["applying"], { status: "applied", commit_sha: outcome.sha, requeue_status: requeue ? "waiting" : null, }); if (requeue) { await env.MERGEFIX_WORKFLOW.create({ id: `${runRow.id}-requeue`, params: { runId: runRow.id, phase: "requeue" }, }).catch(async (error: unknown) => { console.error("Merge fix requeue", error); await abandonRequeue(runRow.id, "Could not schedule the re-enqueue"); }); } return { runId: runRow.id, commitSha: outcome.sha, requeue, alreadyApplied: false }; }