import { createTwoFilesPatch } from "diff"; import type { MergeConflict } from "../git/merge"; import type { ConflictHunk } from "../git/merge3"; import { acceptGenerated, assembleResolution, baseCandidates, blockContext, dedupe, redactSecrets } from "./candidates"; import type { Candidate, SecretVault } from "./candidates"; import { buildClefRequest, decideFile, floorRisk, MAX_HUNKS_PER_FILE } from "./decide"; import type { ClefRequest, ClefResponse, FileDecision, HunkInput, Thresholds } from "./decide"; import { candidateProblem } from "./validate"; export type ChatMessage = { role: "system" | "user"; content: string }; export type TokenUsage = { inputTokens: number; outputTokens: number }; export type GenerationInput = { messages: ChatMessage[]; hunk: ConflictHunk }; /** The two model calls the pipeline makes; production uses AI Gateway, tests and e2e use the mock. */ export interface MergeFixProvider { generate(input: GenerationInput): Promise<{ text: string; usage: TokenUsage }>; decide(request: ClefRequest): Promise; } export type FixLimits = { maxFiles: number; maxHunkBytes: number; maxModelCalls: number; aiCandidates: number; callTimeoutMs: number; /** Wall-clock budget for all model calls, kept below the Workflow plan step's timeout. */ totalTimeoutMs: number; }; export const DEFAULT_LIMITS: FixLimits = { maxFiles: 8, maxHunkBytes: 4096, maxModelCalls: 40, aiCandidates: 2, callTimeoutMs: 30_000, totalTimeoutMs: 7 * 60 * 1000, }; export type HunkResult = { candidates: Candidate[]; selected: string | null; probabilities: Record }; export type FileResult = | { path: string; status: "planned"; hunks: HunkResult[]; decision: FileDecision; resolution: string | null; /** Unified diff from the head branch's version to the proposed resolution; absent on older runs. */ diff?: string | null; } | { path: string; status: "skipped"; reason: string }; export type FixPlan = { files: FileResult[]; calls: number; autoApply: boolean }; const MAX_BODY_CHARS = 2000; export class ModelCallError extends Error {} async function withTimeout(work: Promise, ms: number): Promise { let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new ModelCallError(`Model call timed out after ${ms}ms`)), ms); }); try { return await Promise.race([work, timeout]); } finally { clearTimeout(timer); } } export function generationMessages(input: { path: string; title: string; body: string; hunk: ConflictHunk; context: { before: string; after: string }; vault: SecretVault; variant: number; }): ChatMessage[] { const redact = (text: string) => redactSecrets(text, input.vault); const fence = (label: string, text: string) => `${label}:\n\`\`\`\n${redact(text)}\`\`\``; return [ { role: "system", content: "You resolve git merge conflicts. Combine both sides so every intended change survives. Never output conflict markers. Reply with only the lines that replace the conflict (never repeat the lines before or after it) inside one ```merge fenced block. Keep any __GITFOR_REDACTED_N__ token exactly as written.", }, { role: "user", content: [ `Pull request: ${redact(input.title)}`, `Description:\n${redact(input.body).slice(0, MAX_BODY_CHARS)}`, `File: ${input.path}`, fence("Lines before the conflict", input.context.before), fence("Common ancestor (base)", input.hunk.base), fence("Head branch side (ours)", input.hunk.ours), fence("Base branch side (theirs)", input.hunk.theirs), fence("Lines after the conflict", input.context.after), input.variant > 0 ? "Give a different valid resolution than the most obvious one." : "", ] .filter(Boolean) .join("\n\n"), }, ]; } type PlanContext = { title: string; body: string; provider: MergeFixProvider; thresholds: Thresholds; limits: FixLimits; clefModel: ClefRequest["model"]; /** Called before every model call; throws to stop (for example when a monthly cap is reached). */ meter: (kind: "generate" | "decide", usage: TokenUsage | null) => Promise; calls: number; deadline: number; }; function hunkBytes(hunk: ConflictHunk): number { return hunk.base.length + hunk.ours.length + hunk.theirs.length; } function lines(text: string): string[] { return text.split("\n").filter((line, index, all) => line !== "" || index < all.length - 1); } /** * Model output is untrusted (the PR title, body and code can carry prompt injections), so an AI candidate * is only auto-applied when it keeps every head and base line exactly once, in each side's order, and adds * nothing (not even a line both sides removed). Anything more inventive needs a human. */ export function isInterleaving(text: string, hunk: ConflictHunk): boolean { const out = lines(text); const left = lines(hunk.ours); const right = lines(hunk.theirs); if (out.length !== left.length + right.length) return false; let row: boolean[] = Array.from({ length: right.length + 1 }, () => false); for (let i = 0; i <= left.length; i++) { const next: boolean[] = []; for (let j = 0; j <= right.length; j++) { if (i === 0 && j === 0) next.push(true); else { const fromLeft = i > 0 && row[j] && left[i - 1] === out[i + j - 1]; const fromRight = j > 0 && next[j - 1] && right[j - 1] === out[i + j - 1]; next.push(Boolean(fromLeft || fromRight)); } } row = next; } return row[right.length]; } async function aiCandidates(conflict: MergeConflict, index: number, vault: SecretVault, context: PlanContext) { const hunk = conflict.hunks?.[index]; if (!hunk || conflict.content === null) return []; const out: Candidate[] = []; for (let variant = 0; variant < context.limits.aiCandidates; variant++) { if (context.calls >= context.limits.maxModelCalls - 1 || Date.now() > context.deadline) break; await context.meter("generate", null); context.calls++; const messages = generationMessages({ path: conflict.path, title: context.title, body: context.body, hunk, context: blockContext(conflict.content, index), vault, variant, }); const reply = await withTimeout(context.provider.generate({ messages, hunk }), context.limits.callTimeoutMs).catch( (error: unknown) => { if (error instanceof ModelCallError) return null; throw error; }, ); if (!reply) continue; await context.meter("generate", reply.usage); const text = acceptGenerated(reply.text, vault, context.limits.maxHunkBytes * 2); if (text !== null) out.push({ id: `ai_${variant + 1}`, source: "ai", label: `AI resolution ${variant + 1}`, text }); } return out; } async function planFile(conflict: MergeConflict, context: PlanContext): Promise { const hunks = conflict.hunks ?? []; const skip = (reason: string): FileResult => ({ path: conflict.path, status: "skipped", reason }); if (conflict.content === null || hunks.length === 0) return skip("Not a text conflict"); if (hunks.length > MAX_HUNKS_PER_FILE) return skip(`More than ${MAX_HUNKS_PER_FILE} conflict hunks`); if (hunks.some((hunk) => hunkBytes(hunk) > context.limits.maxHunkBytes)) return skip("A conflict hunk is too large"); if ( assembleResolution( conflict.content, hunks, hunks.map((hunk) => hunk.ours), ) === null ) { return skip("The file already contains conflict-marker-like lines"); } if (context.calls >= context.limits.maxModelCalls) return skip("Model call budget exhausted"); const vault: SecretVault = new Map(); const inputs: HunkInput[] = []; for (const [index, hunk] of hunks.entries()) { const generated = await aiCandidates(conflict, index, vault, context); const check = { path: conflict.path, content: conflict.content, hunks, index }; const candidates = dedupe([...baseCandidates(hunk), ...generated]).filter( (candidate) => candidateProblem(candidate.text, check) === null, ); if (candidates.length === 0) return skip("No candidate resolution passed validation"); inputs.push({ hunk, candidates }); } if (context.calls >= context.limits.maxModelCalls) return skip("Model call budget exhausted"); if (Date.now() > context.deadline) return skip("The run's time budget is exhausted"); const request = buildClefRequest({ model: context.clefModel, path: conflict.path, title: context.title, body: context.body, maxBodyChars: MAX_BODY_CHARS, hunks: inputs, vault, }); await context.meter("decide", null); context.calls++; const response = await withTimeout(context.provider.decide(request), context.limits.callTimeoutMs).catch( (error: unknown) => { if (error instanceof ModelCallError) return null; throw error; }, ); if (!response) return skip("The decision model did not answer in time"); await context.meter("decide", { inputTokens: response.usage?.input_tokens ?? 0, outputTokens: response.usage?.output_tokens ?? 0, }); const decision = decideFile(inputs, response, context.thresholds); const chosen = inputs.map( (entry, index) => entry.candidates.find((candidate) => candidate.id === decision.hunks[index].selected)?.text, ); const resolution = chosen.every((text) => text !== undefined) ? assembleResolution(conflict.content, hunks, chosen as string[]) : null; const picks = inputs.map((entry, index) => entry.candidates.find((candidate) => candidate.id === decision.hunks[index].selected), ); const novel = inputs.some( (entry, index) => picks[index]?.source === "ai" && !isInterleaving(picks[index].text, entry.hunk), ); floorRisk(decision, inputs, picks); if (novel && decision.autoApply) { decision.autoApply = false; decision.reasons.push("An AI candidate rewrites lines instead of combining both sides; review it"); } if (resolution === null && decision.autoApply) { decision.autoApply = false; decision.reasons.push("The selected candidates do not assemble into a clean file"); } return { path: conflict.path, status: "planned", hunks: inputs.map((entry, index) => ({ candidates: entry.candidates, selected: decision.hunks[index].selected, probabilities: decision.hunks[index].probabilities, })), decision, resolution, diff: resolution === null ? null : resolutionDiff(conflict.path, conflict.content, hunks, resolution), }; } /** Shows a proposal as the change it makes to the head branch's side of the conflict. */ function resolutionDiff(path: string, content: string, hunks: ConflictHunk[], resolution: string): string { const head = assembleResolution( content, hunks, hunks.map((hunk) => hunk.ours), ); return createTwoFilesPatch(`a/${path}`, `b/${path}`, head ?? "", resolution, undefined, undefined, { context: 3 }) .split("\n") .filter((line) => !line.startsWith("=====")) .join("\n"); } /** Plans a fix for every text conflict, bounded by file, hunk-size and model-call limits. */ export async function planMergeFix( conflicts: MergeConflict[], options: Omit, ): Promise { const context: PlanContext = { ...options, calls: 0, deadline: Date.now() + options.limits.totalTimeoutMs }; const files: FileResult[] = []; for (const conflict of conflicts.slice(0, options.limits.maxFiles)) files.push(await planFile(conflict, context)); for (const conflict of conflicts.slice(options.limits.maxFiles)) { files.push({ path: conflict.path, status: "skipped", reason: `Only ${options.limits.maxFiles} files are fixed per run`, }); } const autoApply = files.length > 0 && files.every((file) => file.status === "planned" && file.decision.autoApply && file.resolution !== null); return { files, calls: context.calls, autoApply }; }