import { env } from "cloudflare:workers"; import { repoDb } from "../scoped-db"; import { registerLocator } from "../scoped-db/locators"; import { scopedId } from "../scoped-db/ids"; import { forbidden, badRequest } from "../errors"; import { currentBaseSha, headBranchConflicts, loadPullRepos, requireHeadPush, updateHeadBranch } from "../pull-merge"; import { hasPermission, repoPermission } from "../repos"; import { loadPullByIssueId, MergeError } from "../pulls"; import type { PullRow } from "../pulls"; import { actorViewer } from "../merge-automation"; import { clefModel, gatewayProvider } from "./gateway"; import { DEFAULT_LIMITS, ModelCallError, planMergeFix } from "./plan"; import { aiMeter } from "../metering/ai"; import { resolveAiEndpoint } from "../ai/endpoint"; import { assertWithinQuota, QuotaExceededError } from "../metering/quota"; import type { FixPlan } from "./plan"; import { loadRepoSettings, loadRun, ACTIVE_STATUSES, updateRun } from "./store"; import { resolveMode, shouldAutoApply } from "./proposal"; import type { MergeFixMode } from "./proposal"; import type { RunRow } from "./store"; import type { RepoRow, Viewer } from "~/lib/types"; import { captureServerEvent } from "../analytics"; /** Longer than the Workflow's worst case (plan 10 min + apply 5 min), so a live run is never treated as abandoned. */ const ACTIVE_RUN_MS = 30 * 60 * 1000; export class MergeFixError extends Error {} /** Private content only leaves the forge when every private repository involved has opted in. */ async function contentMayLeave(baseRepo: RepoRow, headRepo: RepoRow): Promise { for (const repo of baseRepo.id === headRepo.id ? [baseRepo] : [baseRepo, headRepo]) { if (repo.visibility === "private" && !(await loadRepoSettings(repo.id)).allowPrivate) return false; } return true; } /** Starts a bounded fix run in a Workflow. Callers must already have checked the trigger's own permission. */ export async function startMergeFix( pull: PullRow, actor: Viewer, trigger: RunRow["trigger"], queueMethod: RunRow["queue_method"] = null, requestedMode?: MergeFixMode, ): Promise { if (pull.state !== "open") throw badRequest("Pull request is not open"); const { baseRepo, headRepo } = await loadPullRepos(pull); if (!hasPermission(await repoPermission(baseRepo, actor), "write")) { throw forbidden("Only people with write access to the base repository can start an AI merge fix"); } await requireHeadPush(headRepo, pull.head_ref, actor); if (!(await contentMayLeave(baseRepo, headRepo))) { throw forbidden( "AI merge fixes are not enabled for private repositories here; a repository admin can allow them in settings", ); } const db = repoDb(pull.repo_id); await db.run( "UPDATE mergefix_runs SET status = 'failed', error = 'Abandoned', updated_at = ? WHERE pull_issue_id = ? AND status IN ('queued', 'running', 'applying') AND created_at <= ?", Date.now(), pull.issue_id, Date.now() - ACTIVE_RUN_MS, ); const active = await activeRun(pull); if (active) return active; const accountId = baseRepo.owner_id; await assertWithinQuota(accountId, "ai.calls"); const mode = resolveMode(requestedMode, (await loadRepoSettings(baseRepo.id)).autoApply); const id = scopedId(baseRepo.id); const now = Date.now(); try { await db.run( `INSERT INTO mergefix_runs (id, pull_issue_id, repo_id, account_id, actor_id, trigger, status, head_sha, queue_method, mode, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?)`, id, pull.issue_id, baseRepo.id, accountId, actor.id, trigger, pull.head_sha, queueMethod, mode, now, now, ); } catch (error) { const raced = await activeRun(pull); if (raced) return raced; throw error; } await registerLocator("mergefix_run", id, baseRepo.id); try { await env.MERGEFIX_WORKFLOW.create({ id, params: { runId: id } }); } catch (error) { await updateRun(id, ACTIVE_STATUSES, { status: "failed", error: "Could not start the fix" }); throw error; } captureServerEvent( "mergefix_started", actor.id, { trigger, mode, private_repo: baseRepo.visibility !== "public" }, { ownerAccountId: baseRepo.owner_id }, ); return id; } async function activeRun(pull: PullRow): Promise { const row = await repoDb(pull.repo_id).first<{ id: string }>( "SELECT id FROM mergefix_runs WHERE pull_issue_id = ? AND status IN ('queued', 'running', 'applying')", pull.issue_id, ); return row?.id ?? null; } type Started = { pull: PullRow; actor: Viewer; baseRepo: RepoRow; headRepo: RepoRow }; async function resume(runRow: RunRow): Promise { const pull = await loadPullByIssueId(runRow.repo_id, runRow.pull_issue_id); if (!pull || pull.state !== "open") throw new MergeFixError("The pull request is no longer open"); if (pull.head_sha !== runRow.head_sha) throw new MergeFixError("The head branch changed; start a new fix"); const actor = await actorViewer(runRow.actor_id); if (!actor) throw new MergeFixError("The user who started this fix no longer exists"); const { baseRepo, headRepo } = await loadPullRepos(pull); if (!hasPermission(await repoPermission(baseRepo, actor), "write")) { throw new MergeFixError("The user who started this fix no longer has write access to the base repository"); } await requireHeadPush(headRepo, pull.head_ref, actor).catch(() => { throw new MergeFixError("The user who started this fix can no longer push to the head branch"); }); if (!(await contentMayLeave(baseRepo, headRepo))) { throw new MergeFixError("AI merge fixes are not enabled for this private repository"); } return { pull, actor, baseRepo, headRepo }; } export type PlanSummary = { autoApply: boolean; error: string | null }; /** Workflow step 1: compute conflicts, generate candidates and ask Clef; persists the plan for the editor. */ export async function planRun(runId: string): Promise { const runRow = await loadRun(runId); if (!runRow || !(await updateRun(runId, ["queued"], { status: "running" }))) return { autoApply: false, error: null }; return guarded(runId, async () => { const { pull } = await resume(runRow); const conflicts = await headBranchConflicts(pull); if (!conflicts) throw new MergeFixError("These branches cannot be merged on the web"); if (conflicts.conflicts.length === 0) throw new MergeFixError("There are no conflicts to fix"); if (!conflicts.resolvable) throw new MergeFixError("Some conflicts are not text conflicts and need a local merge"); const byok = await resolveAiEndpoint({ accountId: runRow.account_id, repoId: runRow.repo_id }); const settings = await loadRepoSettings(runRow.repo_id); const metered = aiMeter({ accountId: runRow.account_id, repoId: runRow.repo_id, feature: "mergefix", scope: runId, }); let lastCall = 0; const plan = await planMergeFix(conflicts.conflicts, { title: pull.title, body: pull.body ?? "", provider: gatewayProvider({ accountId: runRow.account_id, runId, byok }), thresholds: settings.thresholds, limits: DEFAULT_LIMITS, clefModel: clefModel(), meter: async (kind, tokens) => { const paidByAccount = kind === "generate" && byok !== null; const model = kind === "decide" ? clefModel() : (byok?.model ?? env.MERGEFIX_MODEL); if (!tokens) lastCall = await metered.beforeCall(model, paidByAccount); else await metered.afterCall(lastCall, model, tokens, paidByAccount); }, }); const autoApply = shouldAutoApply(runRow.mode, plan); const saved = await updateRun(runId, ["running"], { status: autoApply ? "running" : "suggested", base_sha: conflicts.baseSha, calls: plan.calls, result_json: JSON.stringify(plan), }); return { autoApply: saved && autoApply, error: null }; }); } /** Workflow step 2: lands the confident plan through the same path as the web conflict editor. */ export async function applyRun(runId: string): Promise { const runRow = await loadRun(runId); if (!runRow?.result_json || !(await updateRun(runId, ["running"], { status: "applying" }))) { return { autoApply: false, error: null }; } return guarded(runId, async () => { const { pull, actor, baseRepo } = await resume(runRow); if ((await currentBaseSha(baseRepo, pull.base_ref)) !== runRow.base_sha) { await updateRun(runId, ["applying"], { status: "suggested", error: "The base branch moved; review the suggestions or run again", }); return { autoApply: false, error: null }; } const plan = JSON.parse(runRow.result_json ?? "{}") as FixPlan; const resolutions = plan.files.flatMap((file) => file.status === "planned" && file.resolution !== null ? [{ path: file.path, content: file.resolution }] : [], ); if (!shouldAutoApply(runRow.mode, plan) || resolutions.length !== plan.files.length) { await updateRun(runId, ["applying"], { status: "suggested" }); return { autoApply: false, error: null }; } const outcome = await updateHeadBranch(pull, actor, resolutions, runRow.base_sha ?? undefined, [ `X-Gitfor-MergeFix: ${runId}`, "Co-authored-by: gitfor MergeFix ", ]).catch(async (error: unknown) => { if (!(error instanceof MergeError) || (await currentBaseSha(baseRepo, pull.base_ref)) === runRow.base_sha) { throw error; } return { conflicts: [] as const, moved: true }; }); if ("moved" in outcome) { await updateRun(runId, ["applying"], { status: "suggested", error: "The base branch moved; review the suggestions or run again", }); return { autoApply: false, error: null }; } if (outcome.conflicts) { await updateRun(runId, ["applying"], { status: "suggested", error: "New conflicts appeared; review the suggestions", }); return { autoApply: false, error: null }; } await updateRun(runId, ["applying"], { status: "applied", commit_sha: outcome.sha, requeue_status: runRow.queue_method ? "waiting" : null, }); return { autoApply: true, error: null }; }); } /** Expected failures end the run with a message; anything else is rethrown so the Workflow can retry. */ async function guarded(runId: string, work: () => Promise): Promise { try { return await work(); } catch (error) { if ( error instanceof MergeFixError || error instanceof MergeError || error instanceof QuotaExceededError || error instanceof ModelCallError ) { await updateRun(runId, ACTIVE_STATUSES, { status: "failed", error: error.message }); return { autoApply: false, error: error.message }; } throw error; } } export async function failRun(runId: string, message: string): Promise { await updateRun(runId, ACTIVE_STATUSES, { status: "failed", error: message }); } /** Merge queue hook: when the repo opted in, a conflicting entry starts a fix as the user who queued it. */ export async function queueMergeFix( repo: RepoRow, pull: PullRow, actor: Viewer, method: "merge" | "squash", ): Promise { if (!(await loadRepoSettings(repo.id)).queueEnabled) return false; try { await startMergeFix(pull, actor, "queue", method); return true; } catch (error) { console.error("Merge queue AI fix", error); return false; } }