"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { person } from "@/lib/actor"; import { GitError } from "@/lib/git"; import { ProtectionError } from "@/lib/protection"; import { addPullComment, approvePullWorkflows, createPull, getPull, mergePull, PullError, replyToComment, submitReview, updatePull, type LineComment, type MergeMethod, type PullPatch, type Review, } from "@/lib/pulls"; import { canWrite, type RepoRecord } from "@/lib/repos"; import { ActionError, repoForAction } from "./guard"; import type { FormState } from "./types"; // Permissions, as for issues: anyone who can read a repository may comment on its pull requests. // Opening one from a branch of the repository needs push access; from a fork, read access to both. // Its author and people with push access may change it; approving, requesting changes and starting // a fork's workflows need push access, and merging also needs the base branch's rules to be met. const str = (formData: FormData, key: string) => String(formData.get(key) ?? "").trim(); function revalidatePulls(repo: RepoRecord) { revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); } const isUserError = (e: unknown) => e instanceof ActionError || e instanceof PullError || e instanceof ProtectionError || e instanceof GitError; async function pullForAction(owner: string, name: string, number: number, need: "read" | "write") { const { repo, viewer, access } = await repoForAction(owner, name, need); const pr = await getPull(repo.id, number); if (!pr) throw new ActionError("That pull request doesn't exist."); return { repo, viewer, access, pr }; } export async function createPullAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise { let destination: string; try { const { repo, viewer, access } = await repoForAction(owner, name, "read"); // "owner/name" of the fork the head branch is on, when it isn't this repository. const [forkOwner, forkName] = str(formData, "head_repo").split("/"); const headRepo = forkOwner && forkName ? (await repoForAction(forkOwner, forkName, "read")).repo : null; const fromFork = headRepo && headRepo.id !== repo.id; if (!fromFork && !canWrite(access)) { throw new ActionError("You can't push to this repository. Fork it, push your changes to the fork, and open the pull request from there."); } const pr = await createPull(repo, person(viewer.id), { head: str(formData, "head"), headRepo, base: str(formData, "base"), title: str(formData, "title"), body: str(formData, "body"), draft: formData.get("draft") === "on", }); revalidatePulls(repo); destination = `/${repo.owner}/${repo.name}/pulls/${pr.number}`; } catch (e) { if (isUserError(e)) return { error: (e as Error).message }; throw e; } redirect(destination); } export async function updatePullAction(owner: string, name: string, number: number, patch: PullPatch): Promise<{ error?: string }> { try { const { repo, viewer, access, pr } = await pullForAction(owner, name, number, "read"); if (!canWrite(access) && pr.authorId !== viewer.id) throw new ActionError("Only collaborators and its author can change a pull request."); await updatePull(repo, pr, person(viewer.id), patch); revalidatePulls(repo); return {}; } catch (e) { if (isUserError(e)) return { error: (e as Error).message }; throw e; } } export async function editPullAction(owner: string, name: string, number: number, _prev: FormState, formData: FormData): Promise { const base = str(formData, "base"); const result = await updatePullAction(owner, name, number, { title: str(formData, "title"), body: String(formData.get("body") ?? ""), ...(base ? { base } : {}) }); return result.error ? result : { success: "Saved." }; } /** Close, reopen, mark ready: the buttons on a pull request. */ export async function setPullStateAction(owner: string, name: string, number: number, patch: PullPatch) { const result = await updatePullAction(owner, name, number, patch); if (result.error) throw new ActionError(result.error); } export async function commentPullAction(owner: string, name: string, number: number, _prev: FormState, formData: FormData): Promise { try { const { repo, viewer, pr } = await pullForAction(owner, name, number, "read"); await addPullComment(repo, pr, person(viewer.id), str(formData, "body")); revalidatePulls(repo); return { success: "Commented." }; } catch (e) { if (isUserError(e)) return { error: (e as Error).message }; throw e; } } /** The answer box on a pull request: a comment, or (from its other buttons) an approval or a request for changes. */ export async function respondPullAction(owner: string, name: string, number: number, prev: FormState, formData: FormData): Promise { const intent = str(formData, "intent"); if (intent === "approved" || intent === "changes_requested") { formData.set("state", intent); return reviewPullAction(owner, name, number, prev, formData); } return commentPullAction(owner, name, number, prev, formData); } export async function reviewPullAction(owner: string, name: string, number: number, _prev: FormState, formData: FormData): Promise { try { const { repo, viewer, access, pr } = await pullForAction(owner, name, number, "read"); const state = str(formData, "state") as Review["state"]; if (!["approved", "changes_requested", "commented"].includes(state)) throw new ActionError("Choose what the review says."); if (state !== "commented" && !canWrite(access)) throw new ActionError("Only people with push access can approve or request changes."); await submitReview(repo, pr, person(viewer.id), { state, body: str(formData, "body"), comments: [] }); revalidatePulls(repo); return { success: "Review submitted." }; } catch (e) { if (isUserError(e)) return { error: (e as Error).message }; throw e; } } /** A comment on one line of the changes, from the Files tab: a review with just that comment. */ export async function lineCommentAction(owner: string, name: string, number: number, comment: LineComment): Promise<{ error?: string }> { try { const { repo, viewer, pr } = await pullForAction(owner, name, number, "read"); await submitReview(repo, pr, person(viewer.id), { state: "commented", body: "", comments: [comment] }); revalidatePulls(repo); return {}; } catch (e) { if (isUserError(e)) return { error: (e as Error).message }; throw e; } } export async function replyThreadAction( owner: string, name: string, number: number, commentId: string, input: { body: string; resolve?: boolean }, ): Promise<{ error?: string }> { try { const { repo, viewer, access, pr } = await pullForAction(owner, name, number, "read"); if (input.resolve !== undefined && !canWrite(access) && pr.authorId !== viewer.id) { throw new ActionError("Only collaborators and the pull request's author can resolve threads."); } await replyToComment(repo, pr, person(viewer.id), commentId, input.body, input.resolve); revalidatePulls(repo); return {}; } catch (e) { if (isUserError(e)) return { error: (e as Error).message }; throw e; } } export async function mergePullAction(owner: string, name: string, number: number, _prev: FormState, formData: FormData): Promise { try { const { repo, viewer, pr } = await pullForAction(owner, name, number, "write"); const method = (str(formData, "method") || "merge") as MergeMethod; if (method !== "merge" && method !== "squash") throw new ActionError("Unknown merge method."); await mergePull(repo, pr, person(viewer.id), { method, deleteBranch: formData.get("deleteBranch") === "on", expectedHeadSha: str(formData, "headSha") || undefined, }); revalidatePulls(repo); return { success: "Merged." }; } catch (e) { if (isUserError(e)) return { error: (e as Error).message }; throw e; } } /** Starts the workflows of a pull request from a fork whose author can't push here, once someone who can has looked at its changes. */ export async function approveWorkflowsAction(owner: string, name: string, number: number): Promise { try { const { repo, viewer, pr } = await pullForAction(owner, name, number, "write"); const started = await approvePullWorkflows(repo, pr, person(viewer.id)); revalidatePulls(repo); if (!started) return { error: "No workflow runs for this pull request." }; return { success: `Started ${started} workflow${started === 1 ? "" : "s"}.` }; } catch (e) { if (isUserError(e)) return { error: (e as Error).message }; throw e; } }