import { z } from "zod"; import { forbidden, notFoundError } from "../errors"; import { limitHeavy, limitWrite } from "../limits"; import { latestRuns } from "../mergefix/store"; import { startMergeFix } from "../mergefix/service"; import { applyProposal } from "../mergefix/apply"; import { MERGEFIX_MODES } from "../mergefix/proposal"; import type { FixPlan } from "../mergefix/plan"; import type { PullRow } from "../pulls"; import type { RepoRow } from "~/lib/types"; import { loadPull } from "../pulls"; import { assertNotArchived, hasPermission, loadRepoById } from "../repos"; import { scopedRepoPermission } from "../token-scope"; import { readJson, requireScopedRepo } from "./http"; import type { ApiContext } from "./http"; const HTTP_ACCEPTED = 202; const MAX_APPLY_FILES = 100; const startBody = z.object({ mode: z.enum(MERGEFIX_MODES).optional() }); const applyBody = z.object({ files: z.array(z.string().min(1).max(4096)).min(1).max(MAX_APPLY_FILES).optional(), confirm_low_confidence: z.boolean().optional(), }); async function loadScopedPull(ctx: ApiContext, owner: string, name: string, number: number, needed: "read" | "write") { const { repo } = await requireScopedRepo(ctx, owner, name, "pulls", needed); const pull = await loadPull(repo.id, number); if (!pull) throw notFoundError("Pull request not found"); return { repo, pull }; } async function requireHeadToken(ctx: ApiContext, repo: RepoRow, pull: PullRow): Promise { const headRepo = pull.head_repo_id === repo.id ? repo : await loadRepoById(pull.head_repo_id); if (!headRepo) throw notFoundError("Head repository was deleted"); if (!hasPermission(await scopedRepoPermission(headRepo, ctx.credential, "contents"), "write")) { throw forbidden("This token cannot push to the head branch"); } } /** * Starts an AI conflict fix; like update-branch, the token must also be able to push to the head repo. * `{"mode":"auto"}` asks for the one-shot fix; otherwise the repo setting decides and proposals are the default. */ export async function startPullAiFix(ctx: ApiContext, owner: string, name: string, number: number) { await limitWrite(ctx.viewer); const { mode } = await readJson(ctx.request, startBody); const { repo, pull } = await loadScopedPull(ctx, owner, name, number, "write"); await requireHeadToken(ctx, repo, pull); const id = await startMergeFix(pull, ctx.viewer, "button", null, mode); return Response.json({ id }, { status: HTTP_ACCEPTED }); } /** Commits the accepted files (all when omitted) of the latest proposal; 409 when it is stale or already in flight. */ export async function applyPullAiFix(ctx: ApiContext, owner: string, name: string, number: number) { await limitWrite(ctx.viewer); const { files, confirm_low_confidence: confirm } = await readJson(ctx.request, applyBody); const { repo, pull } = await loadScopedPull(ctx, owner, name, number, "read"); assertNotArchived(repo); await requireHeadToken(ctx, repo, pull); const outcome = await applyProposal(pull, ctx.viewer, files, confirm); return Response.json({ id: outcome.runId, status: "applied", commit_sha: outcome.commitSha, requeue: outcome.requeue, already_applied: outcome.alreadyApplied, }); } export async function getPullAiFix(ctx: ApiContext, owner: string, name: string, number: number) { await limitHeavy(ctx.viewer); const { pull } = await loadScopedPull(ctx, owner, name, number, "read"); const [latest] = await latestRuns(pull, 1); if (!latest) throw notFoundError("No AI fix has run for this pull request"); const plan = latest.result_json ? (JSON.parse(latest.result_json) as FixPlan) : null; return Response.json({ id: latest.id, status: latest.status, mode: latest.mode, trigger: latest.trigger, stale: latest.stale === 1 || (latest.status === "suggested" && latest.head_sha !== pull.head_sha), error: latest.error, head_sha: latest.head_sha, base_sha: latest.base_sha, commit_sha: latest.commit_sha, calls: latest.calls, requeue: latest.requeue_status ? { status: latest.requeue_status, error: latest.requeue_error } : null, auto_apply: plan?.autoApply ?? false, files: (plan?.files ?? []).map((file) => file.status === "skipped" ? { path: file.path, status: file.status, reason: file.reason } : { path: file.path, status: file.status, risk: file.decision.risk.level, auto_apply: file.decision.autoApply, reasons: file.decision.reasons, diff: file.diff ?? null, hunks: file.hunks.map((hunk) => ({ selected: hunk.selected, probabilities: hunk.probabilities })), }, ), }); }