Vectrum
Sign In
201 lines · 8.7 KB · TypeScript
"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;
  }
}