import { z } from "zod"; import { repoDb, stmt } from "../../scoped-db"; import { registerLocators, type LocatorKind } from "../../scoped-db/locators"; import { scopedId } from "../../scoped-db/ids"; import { badRequest, conflict, forbidden, HttpError, notFoundError } from "../../errors"; import { createPull, getPull, listPulls, mergePull, updatePullBranch } from "../../api/pulls"; import { headBranchConflicts, requireHeadPush, updateHeadBranch } from "../../pull-merge"; import { pullDiff } from "../../pull-files"; import { loadPull, MergeError } from "../../pulls"; import type { PullRow } from "../../pulls"; import { pullUrl } from "../../pull-access"; import { hasPermission, loadRepoById } from "../../repos"; import { scopedRepoPermission } from "../../token-scope"; import { kickMergeAutomation } from "../../merge-automation"; import { emitNotification, emitWebhook } from "../../events/emit"; import { limitHeavy, limitWrite } from "../../limits"; import { markdownBody, title } from "~/lib/validation"; import type { McpCaller } from "../protocol"; import { apiCtx, apiJson, attributionFooter, attributionNote, canConfirmWithHuman, confirmWithHuman, defineTool, repoArgs, scopedRepo, truncateText, } from "./base"; import { agentReview, requireHumanForMerge } from "./review-outcome"; import { issuesChanged } from "../../repo-state"; const number = z.number().int().positive(); const MAX_PATCH_CHARS = 48 * 1024; const MAX_RESOLVED_CHARS = 1024 * 1024; const HTTP_CONFLICT = 409; async function pullFor( caller: McpCaller, ownerName: string, repoName: string, pullNumber: number, needed: "read" | "write", ) { const scoped = await scopedRepo(caller, ownerName, repoName, "pulls", needed); const pull = await loadPull(scoped.repo.id, pullNumber); if (!pull) throw notFoundError("Pull request not found"); const headRepo = pull.head_repo_id === scoped.repo.id ? scoped.repo : await loadRepoById(pull.head_repo_id); if (!headRepo) throw notFoundError("Head repository was deleted"); return { ...scoped, pull, headRepo }; } const listPullsTool = defineTool({ name: "list_pulls", title: "List pull requests", description: "Pull requests in a repository (open by default).", input: z.object({ ...repoArgs, state: z.enum(["open", "closed", "all"]).default("open") }), write: false, async run({ caller }, args) { const pulls = await apiJson( listPulls(apiCtx(caller, { search: { state: args.state } }), args.owner, args.repo), ); return { structured: { pulls } }; }, }); const getPullTool = defineTool({ name: "get_pull", title: "Get pull request", description: "Pull request metadata plus its changed files and (bounded) unified diff.", input: z.object({ ...repoArgs, number, include_patch: z.boolean().default(true) }), write: false, async run({ caller }, args) { await limitHeavy(caller.viewer); const meta = await apiJson>(getPull(apiCtx(caller), args.owner, args.repo, args.number)); const { repo, pull, headRepo } = await pullFor(caller, args.owner, args.repo, args.number, "read"); const diff = await pullDiff(pull, repo, headRepo, {}); const patch = args.include_patch ? truncateText(diff.diff.patch, MAX_PATCH_CHARS) : null; return { structured: { ...meta, head_sha: pull.head_sha, base_sha: pull.base_sha, mergeable: pull.mergeable, commits: diff.commits.slice(0, 50), files: diff.diff.files.slice(0, 300).map((file) => ({ path: file.path, old_path: file.oldPath, status: file.status, additions: file.additions, deletions: file.deletions, binary: file.binary, })), additions: diff.diff.additions, deletions: diff.diff.deletions, patch: patch?.text ?? null, patch_truncated: (patch?.truncated ?? false) || diff.diff.truncated, }, repoId: repo.id, }; }, }); const openPullTool = defineTool({ name: "open_pull", title: "Open pull request", description: "Open a pull request from head (branch, owner:branch, or head_repo) into base. Attributed to the agent.", input: z.object({ ...repoArgs, title, body: markdownBody.default(""), head: z.string().min(1).max(250), base: z.string().min(1).max(250).optional(), head_repo: z.string().max(200).optional(), }), write: true, async run({ caller }, args) { const { repo } = await scopedRepo(caller, args.owner, args.repo, "pulls", "write"); const pull = await apiJson>( createPull( apiCtx(caller, { body: { title: args.title, body: `${args.body}${attributionFooter(caller)}`, head: args.head, base: args.base ?? repo.default_branch, ...(args.head_repo ? { head_repo: args.head_repo } : {}), }, }), args.owner, args.repo, ), ); return { structured: { pull }, repoId: repo.id }; }, }); const reviewComment = z.object({ path: z.string().min(1).max(4096), line: z.number().int().positive(), side: z.enum(["LEFT", "RIGHT"]).default("RIGHT"), body: markdownBody.min(1), }); async function insertReview( caller: McpCaller, pull: PullRow, state: string, body: string, comments: Array>, ) { const reviewId = scopedId(pull.repo_id); const commentIds = comments.map(() => scopedId(pull.repo_id)); const now = Date.now(); await repoDb(pull.repo_id).batch([ stmt( "INSERT INTO reviews (id, issue_id, author_id, state, body, commit_sha, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", reviewId, pull.issue_id, caller.viewer.id, state, body, pull.head_sha, now, ), ...comments.map((comment, index) => stmt( `INSERT INTO review_comments (id, issue_id, review_id, author_id, path, line, side, start_line, start_side, commit_sha, body, in_reply_to, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?)`, commentIds[index], pull.issue_id, reviewId, caller.viewer.id, comment.path, comment.line, comment.side, pull.head_sha, comment.body, now, now, ), ), stmt("DELETE FROM review_requests WHERE issue_id = ? AND user_id = ?", pull.issue_id, caller.viewer.id), stmt("UPDATE issues SET updated_at = ? WHERE id = ?", now, pull.issue_id), ]); await registerLocators([ ["review", reviewId, pull.repo_id], ...commentIds.map((id): [LocatorKind, string, string] => ["review_comment", id, pull.repo_id]), ]); issuesChanged(pull.repo_id, [pull.issue_id]); return reviewId; } /** Mirrors src/fns/reviews.ts submitReview; approvals still require collaborator permission. */ const reviewPullTool = defineTool({ name: "review_pull", title: "Review pull request", description: "Submit a review (approve, request_changes or comment) with optional line comments on the head commit.", input: z.object({ ...repoArgs, number, event: z.enum(["approve", "request_changes", "comment"]), body: markdownBody.default(""), comments: z.array(reviewComment).max(100).default([]), expected_head: z.string().length(40).optional(), }), write: true, async run({ caller, inputResponses }, args) { await limitWrite(caller.viewer); const { repo, permission, pull } = await pullFor(caller, args.owner, args.repo, args.number, "write"); if (pull.state !== "open") throw badRequest("Pull request is not open"); if (args.event !== "comment" && pull.author_id === caller.viewer.id) { throw badRequest("You cannot approve or request changes on your own pull request"); } if (args.event !== "comment" && !hasPermission(permission, "write")) { throw forbidden("Only collaborators can approve or request changes"); } // An approval is a privileged write that PR content could try to talk the agent into (INT-A14): a human // confirms it when the client can ask; otherwise it is recorded as a comment, so it never counts toward // branch protection or kicks merge automation. if (args.event === "approve") { const pending = confirmWithHuman( { caller, inputResponses }, "confirm_approve", `Approve ${repo.owner_name}/${repo.name}#${pull.number} ("${pull.title}") at ${pull.head_sha.slice(0, 7)}?`, ); if (pending) return pending; } if (args.event === "comment" && !args.body.trim() && args.comments.length === 0) throw badRequest("Add a comment"); if (args.expected_head && pull.head_sha !== args.expected_head) throw conflict("New commits were pushed; review again"); const humanConfirmed = args.event === "approve" && canConfirmWithHuman(caller); const { state, text, unconfirmed: unconfirmedApproval } = agentReview(args.event, humanConfirmed, args.body); const body = text ? `${text}\n\n${attributionNote(caller, "Reviewed")}` : ""; const id = await insertReview(caller, pull, state, body, args.comments); if (state === "approved") await kickMergeAutomation(pull); await emitWebhook(repo.id, "pull_request_review", { action: "submitted", review: { state, body, user: { login: caller.viewer.name }, commit_id: pull.head_sha }, pull_request: { number: pull.number, html_url: pullUrl(repo, pull.number) }, sender: { login: caller.viewer.name, id: caller.viewer.id }, }); await emitNotification({ repoId: repo.id, issueId: pull.issue_id, actorId: caller.viewer.id, reason: "review", title: `${caller.viewer.name} ${state.replace("_", " ")} ${repo.owner_name}/${repo.name}#${pull.number}`, url: pullUrl(repo, pull.number), mentionText: body, directRecipients: [pull.author_id], }); return { structured: { id, state, commit: pull.head_sha, ...(unconfirmedApproval ? { note: "Without human confirmation the approval was recorded as a comment and does not count" } : {}), }, repoId: repo.id, }; }, }); const mergePullTool = defineTool({ name: "merge_pull", title: "Merge pull request", description: "Merge a pull request once branch protection allows it. expected_head guards against racing pushes.", input: z.object({ ...repoArgs, number, method: z.enum(["merge", "squash", "rebase"]).default("merge"), expected_head: z.string().length(40).optional(), }), write: true, destructive: true, async run({ caller, inputResponses }, args) { requireHumanForMerge(canConfirmWithHuman(caller)); const pending = confirmWithHuman( { caller, inputResponses }, "confirm_merge", `Merge ${args.owner}/${args.repo}#${args.number} (${args.method})?`, ); if (pending) return pending; const result = await apiJson<{ merged: boolean; sha: string }>( mergePull( apiCtx(caller, { body: { method: args.method, ...(args.expected_head ? { expected_head: args.expected_head } : {}) }, }), args.owner, args.repo, args.number, ), ); return { structured: result }; }, }); const updateBranchTool = defineTool({ name: "update_pull_branch", title: "Update pull request branch", description: "Merge the base branch into the head branch. On conflicts, returns their paths; use get_pull_conflicts then resolve_pull_conflicts.", input: z.object({ ...repoArgs, number }), write: true, async run({ caller }, args) { const response = await updatePullBranch(apiCtx(caller, { body: {} }), args.owner, args.repo, args.number); const body: Record = await response.json(); if (response.status === HTTP_CONFLICT && Array.isArray(body.conflicts)) return { structured: { updated: false, conflicts: body.conflicts } }; if (!response.ok) throw new HttpError(response.status, String(body.error ?? `HTTP ${response.status}`)); return { structured: { updated: true, sha: body.sha } }; }, }); async function requireHeadWrite(caller: McpCaller, ownerName: string, repoName: string, pullNumber: number) { const found = await pullFor(caller, ownerName, repoName, pullNumber, "write"); if (found.pull.state !== "open") throw badRequest("Pull request is not open"); if (!hasPermission(await scopedRepoPermission(found.headRepo, caller.credential, "contents"), "write")) { throw forbidden("This token cannot push to the head branch"); } await requireHeadPush(found.headRepo, found.pull.head_ref, caller.viewer); return found; } const conflictsTool = defineTool({ name: "get_pull_conflicts", title: "Get merge conflicts", description: "Conflicts from merging base into head, with marker text for content conflicts.", input: z.object({ ...repoArgs, number }), write: false, async run({ caller }, args) { await limitHeavy(caller.viewer); const { pull, repo } = await pullFor(caller, args.owner, args.repo, args.number, "read"); const result = await headBranchConflicts(pull); return { structured: { available: result !== null, resolvable: result?.resolvable ?? false, head_sha: pull.head_sha, conflicts: (result?.conflicts ?? []).slice(0, 50).map((item) => ({ path: item.path, kind: item.kind, content: item.content === null ? null : truncateText(item.content, 32 * 1024).text, })), }, repoId: repo.id, }; }, }); const resolveTool = defineTool({ name: "resolve_pull_conflicts", title: "Resolve merge conflicts", description: "Commit a merge of base into head using your resolved file contents (no conflict markers).", input: z.object({ ...repoArgs, number, expected_head: z.string().length(40), resolutions: z .array(z.object({ path: z.string().min(1), content: z.string().max(MAX_RESOLVED_CHARS) })) .min(1) .max(50), }), write: true, async run({ caller }, args) { await limitWrite(caller.viewer); const { pull, repo } = await requireHeadWrite(caller, args.owner, args.repo, args.number); if (pull.head_sha !== args.expected_head) throw conflict("The head branch changed; fetch conflicts again"); const outcome = await updateHeadBranch(pull, caller.viewer, args.resolutions).catch((error: unknown) => { if (error instanceof MergeError) throw conflict(error.message); throw error; }); if (outcome.conflicts) throw conflict(`${outcome.conflicts.length} file(s) still have unresolved conflicts`); return { structured: { sha: outcome.sha }, repoId: repo.id }; }, }); export const pullTools = [ listPullsTool, getPullTool, openPullTool, reviewPullTool, mergePullTool, updateBranchTool, conflictsTool, resolveTool, ];