"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<FormState> {
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<FormState> {
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<FormState> {
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<FormState> {
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<FormState> {
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<FormState> {
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<FormState> {
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;
}
}