import { z } from "zod"; import { all } from "../db"; import { badRequest, conflict, forbidden, notFoundError } from "../errors"; import { requireHeadPush, updateHeadBranch } from "../pull-merge"; import { directUpdateViolation, loadProtections, protectionFor } from "../protection"; import { limitWrite } from "../limits"; import { findForkHead, mergePullRequest, openPull } from "../pull-create"; import { loadPull, MergeError, PULL_SELECT } from "../pulls"; import type { PullRow } from "../pulls"; import { assertNotArchived, hasPermission, loadRepo, loadRepoById } from "../repos"; import { scopedRepoPermission } from "../token-scope"; import { markdownBody, title } from "~/lib/validation"; import type { RepoRow } from "~/lib/types"; import { pullJson, readJson, requireScopedRepo, requireTokenWrite, unprocessable } from "./http"; import type { ApiContext } from "./http"; const LIST_LIMIT = 100; const HTTP_CONFLICT = 409; export async function listPulls(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "pulls", "read"); const state = ctx.url.searchParams.get("state") ?? "open"; if (state !== "open" && state !== "closed" && state !== "all") throw unprocessable("Invalid state"); const rows = state === "all" ? await all( `${PULL_SELECT} WHERE i.repo_id = ? AND i.is_pull = 1 ORDER BY i.number DESC LIMIT ${LIST_LIMIT}`, repo.id, ) : await all( `${PULL_SELECT} WHERE i.repo_id = ? AND i.is_pull = 1 AND i.state = ? ORDER BY i.number DESC LIMIT ${LIST_LIMIT}`, repo.id, state, ); return Response.json(await Promise.all(rows.map((row) => pullJson(row, repo)))); } export async function getPull(ctx: ApiContext, owner: string, name: string, number: number): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "pulls", "read"); const pull = await loadPull(repo.id, number); if (!pull) throw notFoundError("Pull request not found"); return Response.json(await pullJson(pull, repo)); } const createInput = z.object({ title, body: markdownBody.optional(), head: z.string().min(1), base: z.string().min(1), head_repo: z.string().min(1).optional(), }); /** Head may be `branch`, `owner:branch` or `branch` + `head_repo: "owner/name"`; must be this repo or its fork network. */ async function resolveHeadRepo(ctx: ApiContext, repo: RepoRow, head: string, headRepo: string | undefined) { let ownerName: string | null = null; let repoName: string | null = null; let ref = head; if (headRepo) { const [o, n, ...rest] = headRepo.split("/"); if (!o || !n || rest.length > 0) throw unprocessable("head_repo must be owner/name"); ownerName = o; repoName = n; } else if (head.includes(":")) { ownerName = head.slice(0, head.indexOf(":")); ref = head.slice(head.indexOf(":") + 1); } if (ownerName === null) return { headRepo: repo, ref }; const candidate = repoName ? await loadRepo(ownerName, repoName) : await findForkHead(repo, ownerName); const related = candidate && (candidate.id === repo.id || (candidate.fork_of_id === repo.id && candidate.detached !== 1)); if (!candidate || !related || (await scopedRepoPermission(candidate, ctx.credential, "contents")) === "none") { throw notFoundError("Head repository not found"); } return { headRepo: candidate, ref }; } export async function createPull(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, createInput); const viewer = ctx.viewer; await limitWrite(viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "pulls", "read"); await requireTokenWrite(ctx, repo, ["pulls"]); const { headRepo, ref: headRef } = await resolveHeadRepo(ctx, repo, data.head, data.head_repo); const number = await openPull({ repo, headRepo, headRef, base: data.base, title: data.title, body: data.body ?? "", draft: false, viewer, }); const pull = await loadPull(repo.id, number); if (!pull) throw notFoundError("Pull request not found"); return Response.json(await pullJson(pull, repo), { status: 201 }); } const mergeInput = z.object({ method: z.enum(["merge", "squash", "rebase"]).default("merge"), timezone: z .string() .regex(/^[+-]\d{4}$/) .optional(), expected_head: z.string().regex(/^[0-9a-f]{40}$/, "Must be a 40-character commit SHA"), }); export async function mergePull(ctx: ApiContext, owner: string, name: string, number: number): Promise { const data = await readJson(ctx.request, mergeInput); const viewer = ctx.viewer; await limitWrite(viewer); const { repo, permission } = await requireScopedRepo(ctx, owner, name, "pulls", "write"); const contents = await scopedRepoPermission(repo, ctx.credential, "contents"); if (!hasPermission(contents, "write")) throw forbidden("This token cannot write repository contents"); const pull = await loadPull(repo.id, number); if (!pull) throw notFoundError("Pull request not found"); const headRepo = pull.head_repo_id === repo.id ? repo : await loadRepoById(pull.head_repo_id); if (!headRepo) throw notFoundError("Head repository was deleted"); const sha = await mergePullRequest({ repo, permission: hasPermission(contents, permission) ? permission : contents, pull, headRepo, viewer, method: data.method, message: "", deleteBranch: false, expectedHead: data.expected_head, timezone: data.timezone, }); return Response.json({ merged: true, sha }); } /** Mirrors the web "Update branch": merges base into head; token must also hold contents:write on the head repo. */ export async function updatePullBranch(ctx: ApiContext, owner: string, name: string, number: number) { const viewer = ctx.viewer; await limitWrite(viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "pulls", "read"); assertNotArchived(repo); const pull = await loadPull(repo.id, number); if (!pull) throw notFoundError("Pull request not found"); const headRepo = pull.head_repo_id === repo.id ? repo : await loadRepoById(pull.head_repo_id); if (!headRepo) throw notFoundError("Head repository was deleted"); if (pull.state !== "open") throw badRequest("Pull request is not open"); const headPermission = await scopedRepoPermission(headRepo, ctx.credential, "contents"); if (!hasPermission(headPermission, "write")) throw forbidden("This token cannot push to the head branch"); await requireHeadPush(headRepo, pull.head_ref, viewer); const violation = directUpdateViolation( protectionFor(await loadProtections(headRepo.id), pull.head_ref), headPermission, false, ); if (violation) throw forbidden(violation); let outcome: Awaited>; try { outcome = await updateHeadBranch(pull, viewer); } catch (error) { if (error instanceof MergeError) throw conflict(error.message); throw error; } if (outcome.conflicts) { return Response.json( { error: "Merge conflicts", conflicts: outcome.conflicts.map((item) => item.path) }, { status: HTTP_CONFLICT }, ); } return Response.json({ updated: true, sha: outcome.sha }); }