import { z } from "zod"; import { captureServerEvent } from "./analytics"; import { all, placeholders } from "./db"; import { repoDb, stmt } from "./scoped-db"; import { forgetLocator, registerLocator, registerLocators, type LocatorKind } from "./scoped-db/locators"; import { scopedId } from "./scoped-db/ids"; import { badRequest, conflict, forbidden, notFoundError } from "./errors"; import { emitNotification, emitWebhook } from "./events/emit"; import { kickBranch, kickMergeAutomation } from "./merge-automation"; import { pullUrl } from "./pull-access"; import { addIssueEvent } from "./pulls"; import type { PullRow } from "./pulls"; import { assertNotArchived } from "./repos"; import { assertAssignable } from "./assignable"; import { rerequestReview } from "./review-requests"; import { markdownBody } from "~/lib/validation"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; import { issuesChanged } from "./repo-state"; /** A resolved pull plus the caller's effective permission on its repo (already capped by any token scope). */ export type PullAccess = { repo: RepoRow; permission: Permission; pull: PullRow }; export const reviewCommentInput = z .object({ path: z.string().min(1), line: z.number().int().positive(), side: z.enum(["LEFT", "RIGHT"]), startLine: z.number().int().positive().nullish(), startSide: z.enum(["LEFT", "RIGHT"]).nullish(), body: markdownBody.min(1), }) .refine((comment) => !comment.startLine || comment.startLine < comment.line, "Start line must precede the end line"); export type ReviewCommentInput = z.infer; export type ReviewEvent = "approve" | "request_changes" | "comment"; function canWrite(permission: Permission): boolean { return permission === "write" || permission === "admin"; } /** A locked conversation only accepts review activity from collaborators with write, like issue comments. */ function assertNotLocked(access: PullAccess): void { assertNotArchived(access.repo); if (access.pull.locked === 1 && !canWrite(access.permission)) throw forbidden("This conversation is locked"); } async function insertReviewComments( pull: PullRow, viewer: Viewer, reviewId: string | null, comments: ReviewCommentInput[], inReplyTo: string | null, ) { const now = Date.now(); const ids = comments.map(() => scopedId(pull.repo_id)); await repoDb(pull.repo_id).batch( 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ids[index], pull.issue_id, reviewId, viewer.id, comment.path, comment.line, comment.side, comment.startLine ?? null, comment.startLine ? (comment.startSide ?? comment.side) : null, pull.head_sha, comment.body, inReplyTo, now, now, ), ), ); await registerLocators(ids.map((id): [LocatorKind, string, string] => ["review_comment", id, pull.repo_id])); } /** Requires read access; approving or requesting changes additionally requires write. */ export async function submitReview( access: PullAccess, viewer: Viewer, input: { event: ReviewEvent; body: string; comments: ReviewCommentInput[]; expectedHead: string }, ): Promise { const { repo, permission, pull } = access; assertNotLocked(access); if (input.event !== "comment" && pull.author_id === viewer.id) throw badRequest("You cannot approve or request changes on your own pull request"); if (input.event !== "comment" && permission === "read") throw forbidden("Only collaborators can approve or request changes"); if (input.event === "comment" && !input.body.trim() && input.comments.length === 0) throw badRequest("Add a comment or review comments"); if (pull.head_sha !== input.expectedHead) throw conflict("New commits were pushed since you loaded this pull request; reload and review them"); const state = input.event === "approve" ? "approved" : input.event === "request_changes" ? "changes_requested" : "commented"; const reviewId = scopedId(pull.repo_id); const db = repoDb(pull.repo_id); await db.run( "INSERT INTO reviews (id, issue_id, author_id, state, body, commit_sha, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", reviewId, pull.issue_id, viewer.id, state, input.body, pull.head_sha, Date.now(), ); await registerLocator("review", reviewId, pull.repo_id); await insertReviewComments(pull, viewer, reviewId, input.comments, null); await db.batch([ stmt("DELETE FROM review_requests WHERE issue_id = ? AND user_id = ?", pull.issue_id, viewer.id), stmt("UPDATE issues SET updated_at = ? WHERE id = ?", Date.now(), pull.issue_id), ]); issuesChanged(repo.id, [pull.issue_id]); captureServerEvent( "review_submitted", viewer.id, { state, comments: input.comments.length, private_repo: repo.visibility !== "public" }, { ownerAccountId: repo.owner_id }, ); if (state === "approved") await kickMergeAutomation(pull); await emitWebhook(repo.id, "pull_request_review", { action: "submitted", review: { state, body: input.body, user: { login: viewer.name }, commit_id: pull.head_sha }, pull_request: { number: pull.number, html_url: pullUrl(repo, pull.number) }, sender: { login: viewer.name, id: viewer.id }, }); await emitNotification({ repoId: repo.id, issueId: pull.issue_id, actorId: viewer.id, reason: "review", title: `${viewer.name} ${state.replace("_", " ")} ${repo.owner_name}/${repo.name}#${pull.number}`, url: pullUrl(repo, pull.number), mentionText: input.body, directRecipients: [pull.author_id], }); } /** Requires read access. */ export async function replyReviewComment( access: PullAccess, viewer: Viewer, input: { commentId: string | null; comment: ReviewCommentInput }, ): Promise { const { repo, pull } = access; assertNotLocked(access); await insertReviewComments(pull, viewer, null, [input.comment], input.commentId); await emitWebhook(repo.id, "pull_request_review_comment", { action: "created", comment: { path: input.comment.path, line: input.comment.line, body: input.comment.body, user: { login: viewer.name }, }, pull_request: { number: pull.number, html_url: pullUrl(repo, pull.number) }, }); await emitNotification({ repoId: repo.id, issueId: pull.issue_id, actorId: viewer.id, reason: "comment", title: `${viewer.name} commented on ${repo.owner_name}/${repo.name}#${pull.number}`, url: `${pullUrl(repo, pull.number)}/files`, mentionText: input.comment.body, directRecipients: [], }); } /** Requires read access; only collaborators with write, the pull author or the thread's author may resolve. */ export async function resolveReviewThread( access: PullAccess, viewer: Viewer, input: { commentId: string; resolved: boolean }, ): Promise { const db = repoDb(access.pull.repo_id); const root = await db.first<{ author_id: string }>( "SELECT author_id FROM review_comments WHERE id = ? AND issue_id = ? AND in_reply_to IS NULL", input.commentId, access.pull.issue_id, ); if (!root) throw notFoundError("Review thread not found"); const allowed = canWrite(access.permission) || access.pull.author_id === viewer.id || root.author_id === viewer.id; if (!allowed) throw forbidden("Only the thread author, the pull request author or collaborators can resolve threads"); await db.run( "UPDATE review_comments SET resolved = ? WHERE issue_id = ? AND (id = ?3 OR in_reply_to = ?3)", input.resolved ? 1 : 0, access.pull.issue_id, input.commentId, ); } /** Requires write access. Replaces the requested reviewer set. */ export async function requestReviewers(access: PullAccess, viewer: Viewer, usernames: string[]): Promise { const { repo, pull } = access; const users = usernames.length ? await all<{ id: string; name: string }>( `SELECT a.id, a.name FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.name IN (${placeholders(usernames.length)})`, ...usernames, ) : []; await assertAssignable(repo, users); await repoDb(pull.repo_id).batch([ users.length ? stmt( `DELETE FROM review_requests WHERE issue_id = ? AND user_id NOT IN (${placeholders(users.length)})`, pull.issue_id, ...users.map((user) => user.id), ) : stmt("DELETE FROM review_requests WHERE issue_id = ?", pull.issue_id), ...users.map((user) => stmt( "INSERT OR IGNORE INTO review_requests (issue_id, user_id, created_at) VALUES (?, ?, ?)", pull.issue_id, user.id, Date.now(), ), ), ]); issuesChanged(repo.id, [pull.issue_id]); await addIssueEvent(pull, viewer.id, "review_requested", { usernames }); if (users.length > 0) { await emitNotification({ repoId: repo.id, issueId: null, actorId: viewer.id, reason: "review_requested", title: `${viewer.name} requested your review on ${repo.owner_name}/${repo.name}#${pull.number}`, url: pullUrl(repo, pull.number), mentionText: null, directRecipients: users.map((user) => user.id), }); } } /** Requires write access; only repository admins or the review's author may dismiss it. */ export async function dismissReview( access: PullAccess, viewer: Viewer, input: { reviewId: string; reason: string }, ): Promise { const { pull } = access; const db = repoDb(pull.repo_id); const review = await db.first<{ author_id: string; state: string }>( "SELECT author_id, state FROM reviews WHERE id = ? AND issue_id = ?", input.reviewId, pull.issue_id, ); if (!review) throw notFoundError("Review not found"); if (access.permission !== "admin" && review.author_id !== viewer.id) { throw forbidden("Only repository admins or the reviewer can dismiss a review"); } if (review.state !== "approved" && review.state !== "changes_requested") { throw badRequest("Only approvals and change requests can be dismissed"); } await db.run( "UPDATE reviews SET state = 'dismissed', dismissed_by = ?, dismiss_reason = ? WHERE id = ?", viewer.id, input.reason, input.reviewId, ); await addIssueEvent(pull, viewer.id, "review_dismissed", { reason: input.reason, state: review.state, }); await kickBranch(pull.repo_id, pull.base_ref); } /** Requires read access; only the pull author or a collaborator may re-request. */ export async function rerequestPullReview(access: PullAccess, viewer: Viewer, userId: string): Promise { const { repo, permission, pull } = access; if (pull.author_id !== viewer.id && permission === "read") throw forbidden(); if (!(await rerequestReview(repo, pull, viewer, userId))) throw badRequest("That user has not reviewed yet"); } /** The comment's author may edit it; admins may edit too (moderation). Locked conversations stay frozen for readers. */ export async function editReviewComment( access: PullAccess, viewer: Viewer, input: { commentId: string; body: string }, ): Promise { assertNotLocked(access); const db = repoDb(access.pull.repo_id); const comment = await db.first<{ author_id: string }>( "SELECT author_id FROM review_comments WHERE id = ? AND issue_id = ? AND hidden_at IS NULL", input.commentId, access.pull.issue_id, ); if (!comment) throw notFoundError("Review comment not found"); if (comment.author_id !== viewer.id && access.permission !== "admin") throw forbidden(); await db.run( "UPDATE review_comments SET body = ?, updated_at = ? WHERE id = ? AND issue_id = ?", input.body, Date.now(), input.commentId, access.pull.issue_id, ); } /** Deleting a thread root removes its replies too (`in_reply_to ... ON DELETE CASCADE`). */ export async function deleteReviewComment(access: PullAccess, viewer: Viewer, commentId: string): Promise { const db = repoDb(access.pull.repo_id); const comment = await db.first<{ author_id: string }>( "SELECT author_id FROM review_comments WHERE id = ? AND issue_id = ? AND hidden_at IS NULL", commentId, access.pull.issue_id, ); if (!comment) throw notFoundError("Review comment not found"); if (comment.author_id !== viewer.id && access.permission !== "admin") throw forbidden(); const doomed = await db.all<{ id: string }>( "SELECT id FROM review_comments WHERE issue_id = ? AND (id = ?2 OR in_reply_to = ?2)", access.pull.issue_id, commentId, ); await db.run( "DELETE FROM review_comments WHERE issue_id = ? AND (id = ?2 OR in_reply_to = ?2)", access.pull.issue_id, commentId, ); await Promise.all(doomed.map((row) => forgetLocator("review_comment", row.id))); }