import { z } from "zod"; import { first } from "../db"; import { repoDb } from "../scoped-db"; import { notFoundError } from "../errors"; import { limitWrite } from "../limits"; import { setAutoMerge, setMergeQueue, updatePull } from "../pull-automation"; import { loadPull } from "../pulls"; import { deleteReviewComment, dismissReview, editReviewComment, replyReviewComment, requestReviewers, rerequestPullReview, resolveReviewThread, reviewCommentInput, submitReview, } from "../reviews-write"; import { markdownBody } from "~/lib/validation"; import { noContent, pullJson, readJson, requireScopedPull, unprocessable } from "./http"; import type { ApiContext } from "./http"; const sha = z.string().regex(/^[0-9a-f]{40}$/, "Must be a 40-character commit SHA"); const side = z.enum(["LEFT", "RIGHT"]); const reviewInput = z.object({ event: z .string() .transform((value) => value.toLowerCase()) .pipe(z.enum(["approve", "request_changes", "comment"])), body: markdownBody.default(""), commit_id: sha, comments: z .array( z.object({ path: z.string().min(1), line: z.number().int().positive(), side: side.default("RIGHT"), start_line: z.number().int().positive().nullish(), start_side: side.nullish(), body: markdownBody.min(1), }), ) .max(100) .default([]), }); export async function createReview(ctx: ApiContext, owner: string, name: string, number: number) { const data = await readJson(ctx.request, reviewInput); const comments = data.comments.map((comment) => reviewCommentInput.parse({ path: comment.path, line: comment.line, side: comment.side, startLine: comment.start_line, startSide: comment.start_side, body: comment.body, }), ); await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "read", ["pulls"]); await submitReview(access, ctx.viewer, { event: data.event, body: data.body, comments, expectedHead: data.commit_id, }); return Response.json({ ok: true }, { status: 201 }); } const replyInput = z.object({ body: markdownBody.min(1) }); /** The reply is anchored to the parent comment's path, line and side, as the web reply box does. */ export async function replyToComment(ctx: ApiContext, owner: string, name: string, number: number, id: string) { const data = await readJson(ctx.request, replyInput); await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "read", ["pulls"]); const parent = await repoDb(access.repo.id).first<{ path: string; line: number; side: "LEFT" | "RIGHT"; start_line: number | null; start_side: "LEFT" | "RIGHT" | null; }>( "SELECT path, line, side, start_line, start_side FROM review_comments WHERE id = ? AND issue_id = ?", id, access.pull.issue_id, ); if (!parent) throw notFoundError("Review comment not found"); const comment = reviewCommentInput.parse({ path: parent.path, line: parent.line, side: parent.side, startLine: parent.start_line, startSide: parent.start_side, body: data.body, }); await replyReviewComment(access, ctx.viewer, { commentId: id, comment }); return Response.json({ ok: true }, { status: 201 }); } const resolveInput = z.object({ resolved: z.boolean() }); export async function resolveThread(ctx: ApiContext, owner: string, name: string, number: number, id: string) { const data = await readJson(ctx.request, resolveInput); await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "read", ["pulls"]); await resolveReviewThread(access, ctx.viewer, { commentId: id, resolved: data.resolved }); return noContent(); } const editCommentInput = z.object({ body: markdownBody.min(1) }); export async function patchReviewComment(ctx: ApiContext, owner: string, name: string, number: number, id: string) { const data = await readJson(ctx.request, editCommentInput); await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "read", ["pulls"]); await editReviewComment(access, ctx.viewer, { commentId: id, body: data.body }); return noContent(); } export async function deleteReviewCommentApi(ctx: ApiContext, owner: string, name: string, number: number, id: string) { await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "read", ["pulls"]); await deleteReviewComment(access, ctx.viewer, id); return noContent(); } const reviewersInput = z.object({ reviewers: z.array(z.string().min(1)).max(15) }); export async function setRequestedReviewers(ctx: ApiContext, owner: string, name: string, number: number) { const data = await readJson(ctx.request, reviewersInput); await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "write", ["pulls"]); await requestReviewers(access, ctx.viewer, data.reviewers); return noContent(); } const rerequestInput = z .object({ user_id: z.string().min(1).optional(), login: z.string().min(1).optional() }) .refine( (value) => (value.user_id === undefined) !== (value.login === undefined), "Provide exactly one of user_id or login", ); async function userIdForLogin(login: string): Promise { const user = await first<{ id: string }>( "SELECT a.id FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.name = ?", login, ); if (!user) throw notFoundError("User not found"); return user.id; } export async function rerequestReviewer(ctx: ApiContext, owner: string, name: string, number: number) { const data = await readJson(ctx.request, rerequestInput); await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "read", ["pulls"]); const userId = data.user_id ?? (await userIdForLogin(data.login ?? "")); await rerequestPullReview(access, ctx.viewer, userId); return noContent(); } const dismissInput = z.object({ message: z.string().trim().min(1).max(1000) }); export async function dismissPullReview(ctx: ApiContext, owner: string, name: string, number: number, id: string) { const data = await readJson(ctx.request, dismissInput); await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "write", ["pulls"]); await dismissReview(access, ctx.viewer, { reviewId: id, reason: data.message }); return noContent(); } const autoMergeInput = z.object({ method: z.enum(["merge", "squash", "rebase"]).default("merge"), message: z.string().max(10_000).default(""), expected_head: sha, }); export async function enableAutoMerge(ctx: ApiContext, owner: string, name: string, number: number) { const data = await readJson(ctx.request, autoMergeInput); await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "write", ["pulls", "contents"]); await setAutoMerge(access, ctx.viewer, { enabled: true, method: data.method, message: data.message, expectedHead: data.expected_head, }); return noContent(); } export async function disableAutoMerge(ctx: ApiContext, owner: string, name: string, number: number) { await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "write", ["pulls", "contents"]); await setAutoMerge(access, ctx.viewer, { enabled: false, method: "merge", message: "" }); return noContent(); } const queueInput = z.object({ method: z.enum(["merge", "squash"]).default("merge"), expected_head: sha }); export async function enqueuePull(ctx: ApiContext, owner: string, name: string, number: number) { const data = await readJson(ctx.request, queueInput); await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "write", ["pulls", "contents"]); await setMergeQueue(access, ctx.viewer, { queued: true, method: data.method, expectedHead: data.expected_head }); return noContent(); } export async function dequeuePull(ctx: ApiContext, owner: string, name: string, number: number) { await limitWrite(ctx.viewer); const access = await requireScopedPull(ctx, owner, name, number, "write", ["pulls", "contents"]); await setMergeQueue(access, ctx.viewer, { queued: false, method: "merge" }); return noContent(); } const patchInput = z.object({ base: z.string().min(1).optional(), draft: z.boolean().optional(), title: z.unknown().optional(), body: z.unknown().optional(), state: z.unknown().optional(), }); export async function patchPull(ctx: ApiContext, owner: string, name: string, number: number) { const data = await readJson(ctx.request, patchInput); await limitWrite(ctx.viewer); if (data.title !== undefined || data.body !== undefined || data.state !== undefined) { throw unprocessable("Only base and draft can be changed through this endpoint"); } const access = await requireScopedPull(ctx, owner, name, number, "read", ["pulls"]); await updatePull(access, ctx.viewer, { base: data.base, draft: data.draft }); const pull = await loadPull(access.repo.id, number); if (!pull) throw notFoundError("Pull request not found"); return Response.json(await pullJson(pull, access.repo)); }