import { z } from "zod"; import { all } from "../db"; import { badRequest, conflict, forbidden, notFoundError } from "../errors"; import { loadIssue } from "../issues"; import { limitHeavy, limitWrite } from "../limits"; import { pullAutomation } from "../merge-queue"; import { codeOwnerBlockers } from "../merge-automation"; import { pullCodeOwners } from "../codeowners"; import { openPull } from "../pull-create"; import { linkIssue, listLinkedIssues, unlinkIssue } from "../pull-links"; import { headBranchConflicts, requireHeadPush, updateHeadBranch } from "../pull-merge"; import { readPullTemplate } from "../pull-templates"; import { addIssueEvent, loadPull, mergeGate, MergeError } from "../pulls"; import type { PullRow } from "../pulls"; import { hasPermission, loadRepoById } from "../repos"; import { combinedState, listCommitStatuses, setCommitStatus } from "../statuses"; import { applySuggestions, MAX_BATCHED_SUGGESTIONS } from "../suggestions"; import { scopedRepoPermission } from "../token-scope"; import { markdownBody, title } from "~/lib/validation"; import { noContent, pullJson, readJson, requireScopedRepo, unprocessable } from "./http"; import type { ApiContext } from "./http"; const HTTP_CONFLICT = 409; const MAX_RESOLVED_CHARS = 1024 * 1024; const MAX_LISTED = 100; const SHA = /^[0-9a-f]{40}$/; function rethrowMerge(error: unknown): never { if (error instanceof MergeError) throw conflict(error.message); throw error; } async function scopedPull(ctx: ApiContext, owner: string, name: string, number: number, needed: "read" | "write") { const scoped = await requireScopedRepo(ctx, owner, name, "pulls", needed); const pull = await loadPull(scoped.repo.id, number); 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 }; } /** Same guard as the MCP conflict/suggestion tools: the token must be able to push the head branch. */ async function headWritablePull(ctx: ApiContext, owner: string, name: string, number: number) { const found = await scopedPull(ctx, owner, name, number, "write"); if (found.pull.state !== "open") throw badRequest("Pull request is not open"); if (!hasPermission(await scopedRepoPermission(found.headRepo, ctx.credential, "contents"), "write")) { throw forbidden("This token cannot push to the head branch"); } await requireHeadPush(found.headRepo, found.pull.head_ref, ctx.viewer); return found; } /** Merge box state: mergeability, the branch-protection gate, auto-merge and merge-queue position. */ export async function getPullStatus(ctx: ApiContext, owner: string, name: string, number: number) { const { repo, permission, pull } = await scopedPull(ctx, owner, name, number, "read"); const [gate, automation, statuses] = await Promise.all([ mergeGate(pull, permission), pullAutomation(pull), listCommitStatuses(repo.id, pull.head_sha), ]); return Response.json({ ...(await pullJson(pull, repo)), draft: pull.draft === 1, head_sha: pull.head_sha, base_sha: pull.base_sha, mergeable: pull.mergeable, merge_method: pull.merge_method, merge_commit_sha: pull.merge_commit_sha, gate, combined_status: combinedState(statuses), auto_merge: automation.autoMerge && { method: automation.autoMerge.method }, queue: automation.queue, }); } export async function getPullConflicts(ctx: ApiContext, owner: string, name: string, number: number) { await limitHeavy(ctx.viewer); const { pull } = await scopedPull(ctx, owner, name, number, "read"); const result = await headBranchConflicts(pull).catch(rethrowMerge); return Response.json({ available: result !== null, resolvable: result?.resolvable ?? false, head_sha: pull.head_sha, conflicts: (result?.conflicts ?? []).map((item) => ({ path: item.path, kind: item.kind, content: item.content })), }); } const resolveInput = z.object({ 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), }); export async function resolvePullConflicts(ctx: ApiContext, owner: string, name: string, number: number) { const data = await readJson(ctx.request, resolveInput); await limitWrite(ctx.viewer); const { pull } = await headWritablePull(ctx, owner, name, number); if (pull.head_sha !== data.expected_head) throw conflict("The head branch changed; fetch conflicts again"); const outcome = await updateHeadBranch(pull, ctx.viewer, data.resolutions).catch(rethrowMerge); if (outcome.conflicts) { return Response.json( { error: "Unresolved conflicts", conflicts: outcome.conflicts.map((item) => item.path) }, { status: HTTP_CONFLICT }, ); } return Response.json({ sha: outcome.sha }); } const suggestionsInput = z.object({ comment_ids: z.array(z.string().min(1)).min(1).max(MAX_BATCHED_SUGGESTIONS), message: z.string().trim().max(10_000).default(""), }); export async function commitSuggestions(ctx: ApiContext, owner: string, name: string, number: number) { const data = await readJson(ctx.request, suggestionsInput); await limitWrite(ctx.viewer); const { pull, headRepo } = await headWritablePull(ctx, owner, name, number); const fallback = data.comment_ids.length === 1 ? "Apply suggestion from code review" : "Apply suggestions from code review"; const sha = await applySuggestions({ pull, headRepo, viewer: ctx.viewer, commentIds: data.comment_ids, message: data.message || fallback, }).catch(rethrowMerge); return Response.json({ sha }); } /** Read-only views of review state; writes stay in src/fns/reviews.ts. */ export async function listReviews(ctx: ApiContext, owner: string, name: string, number: number) { const { pull } = await scopedPull(ctx, owner, name, number, "read"); const [reviews, comments] = await Promise.all([ all<{ id: string; author: string; state: string; body: string; commit_sha: string | null }>( `SELECT r.id, COALESCE(a.name, 'ghost') AS author, r.state, r.body, r.commit_sha FROM reviews r LEFT JOIN accounts a ON a.id = r.author_id WHERE r.issue_id = ? ORDER BY r.created_at LIMIT ${MAX_LISTED}`, pull.issue_id, ), all<{ id: string; review_id: string | null; author: string; path: string; line: number; side: string; body: string; in_reply_to: string | null; resolved: number; suggestion_applied_sha: string | null; }>( `SELECT c.id, c.review_id, COALESCE(a.name, 'ghost') AS author, c.path, c.line, c.side, c.body, c.in_reply_to, c.resolved, c.suggestion_applied_sha FROM review_comments c LEFT JOIN accounts a ON a.id = c.author_id WHERE c.issue_id = ? ORDER BY c.created_at LIMIT ${MAX_LISTED}`, pull.issue_id, ), ]); return Response.json({ reviews, comments: comments.map((comment) => ({ ...comment, resolved: comment.resolved === 1 })), }); } async function requestedReviewers(pull: PullRow) { return all<{ login: string; code_owner: number }>( `SELECT a.name AS login, COALESCE(r.code_owner, 0) AS code_owner FROM review_requests r JOIN accounts a ON a.id = r.user_id WHERE r.issue_id = ? ORDER BY a.name LIMIT ${MAX_LISTED}`, pull.issue_id, ); } export async function listRequestedReviewers(ctx: ApiContext, owner: string, name: string, number: number) { const { pull } = await scopedPull(ctx, owner, name, number, "read"); const rows = await requestedReviewers(pull); return Response.json(rows.map((row) => ({ login: row.login, code_owner: row.code_owner === 1 }))); } export async function getPullCodeOwners(ctx: ApiContext, owner: string, name: string, number: number) { await limitHeavy(ctx.viewer); const { repo, pull, headRepo } = await scopedPull(ctx, owner, name, number, "read"); const { owned, incomplete } = await pullCodeOwners(pull, repo, headRepo); return Response.json({ owners: Object.fromEntries(owned), incomplete, blockers_without_approvals: await codeOwnerBlockers(pull, new Set()), }); } export async function listPullLinkedIssues(ctx: ApiContext, owner: string, name: string, number: number) { const { pull } = await scopedPull(ctx, owner, name, number, "read"); return Response.json(await listLinkedIssues(pull)); } export async function setPullLinkedIssue( ctx: ApiContext, owner: string, name: string, number: number, issueNumber: number, linked: boolean, ) { await limitWrite(ctx.viewer); const { repo, pull } = await scopedPull(ctx, owner, name, number, "write"); const issue = await loadIssue(repo.id, issueNumber); if (!issue || issue.is_pull === 1) throw notFoundError("Issue not found"); if (linked) await linkIssue(pull.issue_id, issue.id, ctx.viewer.id); else await unlinkIssue(pull.issue_id, issue.id); await addIssueEvent(pull.issue_id, ctx.viewer.id, linked ? "issue_linked" : "issue_unlinked", { number: issue.number, }); return linked ? Response.json({ linked: true }) : noContent(); } export async function getPullTemplate(ctx: ApiContext, owner: string, name: string) { const { repo } = await requireScopedRepo(ctx, owner, name, "pulls", "read"); const base = ctx.url.searchParams.get("base") ?? repo.default_branch; return Response.json({ base, template: await readPullTemplate(repo, base) }); } const draftInput = z.object({ title, body: markdownBody.optional(), head: z.string().min(1), base: z.string().min(1) }); /** Opens a draft from a same-repository branch through the shared openPull path. */ export async function createDraftPull(ctx: ApiContext, owner: string, name: string) { const data = await readJson(ctx.request, draftInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "pulls", "write"); if (data.head.includes(":")) throw unprocessable("Draft pull requests must come from a branch of this repository"); const number = await openPull({ repo, headRepo: repo, headRef: data.head, base: data.base, title: data.title, body: data.body ?? "", draft: true, viewer: ctx.viewer, }); const pull = await loadPull(repo.id, number); if (!pull) throw notFoundError("Pull request not found"); return Response.json({ ...(await pullJson(pull, repo)), draft: pull.draft === 1 }, { status: 201 }); } function commitSha(raw: string): string { const sha = raw.toLowerCase(); if (!SHA.test(sha)) throw unprocessable("sha must be a full 40-character commit id"); return sha; } export async function listStatuses(ctx: ApiContext, owner: string, name: string, rawSha: string) { const sha = commitSha(rawSha); const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "read"); const statuses = await listCommitStatuses(repo.id, sha); return Response.json({ state: combinedState(statuses), statuses: statuses.map((status) => ({ context: status.context, state: status.state, description: status.description, target_url: status.target_url, source: status.source, })), }); } const statusInput = z.object({ state: z.enum(["pending", "success", "failure", "error"]), context: z.string().trim().min(1).max(255).default("default"), description: z.string().max(1000).nullish(), target_url: z.string().url().max(2048).nullish(), }); /** External statuses use source "api", which setCommitStatus never lets overwrite a CI-owned context. */ export async function createStatus(ctx: ApiContext, owner: string, name: string, rawSha: string) { const sha = commitSha(rawSha); const data = await readJson(ctx.request, statusInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "write"); await setCommitStatus({ repoId: repo.id, sha, context: data.context, state: data.state, description: data.description ?? null, targetUrl: data.target_url ?? null, source: "api", }); return Response.json({ context: data.context, state: data.state }, { status: 201 }); }