Vectrum
Sign In
176 lines · 6.5 KB · TypeScript
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
  ESTIMATES,
  STATUS_IDS,
  isValidDay,
  type ActionResult,
  type IssueLabel,
  type IssuePatch,
  type NewIssueInput,
} from "@/lib/issue-meta";
import {
  addComment,
  createIssue,
  createLabel,
  deleteIssue,
  deleteLabel,
  getIssue,
  LabelError,
  listLabels,
  listMembers,
  updateIssue,
} from "@/lib/issues";
import { canWrite, type RepoRecord } from "@/lib/repos";
import { ActionError, repoForAction } from "./guard";
import type { FormState } from "./types";

const MAX_BODY = 65_536;

function revalidateIssues(repo: RepoRecord) {
  revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
}

/**
 * Validates untrusted issue properties from the browser. Only the fields that
 * are present are checked; anything invalid throws.
 */
async function validate(repo: RepoRecord, patch: IssuePatch): Promise<IssuePatch> {
  const out: IssuePatch = {};
  if (patch.title !== undefined) {
    const title = String(patch.title).trim().slice(0, 256);
    if (!title) throw new ActionError("Title can't be blank.");
    out.title = title;
  }
  if (patch.body !== undefined) out.body = String(patch.body).trim().slice(0, MAX_BODY);
  if (patch.status !== undefined) {
    if (!STATUS_IDS.includes(patch.status)) throw new ActionError("Unknown status.");
    out.status = patch.status;
  }
  if (patch.priority !== undefined) {
    if (!Number.isInteger(patch.priority) || patch.priority < 0 || patch.priority > 4) throw new ActionError("Unknown priority.");
    out.priority = patch.priority;
  }
  if (patch.assigneeId !== undefined) {
    if (patch.assigneeId !== null && !(await listMembers(repo.id, repo.ownerId)).some((m) => m.id === patch.assigneeId)) {
      throw new ActionError("Only the owner and collaborators can be assigned.");
    }
    out.assigneeId = patch.assigneeId;
  }
  if (patch.estimate !== undefined) {
    if (patch.estimate !== null && !(ESTIMATES as readonly number[]).includes(patch.estimate)) throw new ActionError("Unknown estimate.");
    out.estimate = patch.estimate;
  }
  if (patch.dueDate !== undefined) {
    if (patch.dueDate !== null && !isValidDay(String(patch.dueDate))) throw new ActionError("Invalid due date.");
    out.dueDate = patch.dueDate;
  }
  if (patch.labelIds !== undefined) {
    if (!Array.isArray(patch.labelIds)) throw new ActionError("Invalid labels.");
    const known = new Set((await listLabels(repo.id)).map((l) => l.id));
    const ids = [...new Set(patch.labelIds)];
    if (!ids.every((id) => known.has(id))) throw new ActionError("Unknown label.");
    out.labelIds = ids;
  }
  if (patch.sortOrder !== undefined) {
    if (typeof patch.sortOrder !== "number" || !Number.isFinite(patch.sortOrder)) throw new ActionError("Invalid order.");
    out.sortOrder = patch.sortOrder;
  }
  return out;
}

// Permissions: anyone who can read a repository may open issues and comment.
// Changing an issue in any way (title, description, status, triage, order) needs
// push access. Deleting is reserved for the issue's creator and the repository owner.

export async function createIssueAction(
  owner: string,
  name: string,
  input: NewIssueInput,
): Promise<ActionResult & { number?: number }> {
  try {
    const { repo, viewer, access } = await repoForAction(owner, name, "read");
    const { title, body, ...properties } = await validate(repo, { ...input, sortOrder: undefined });
    if (!title) return { error: "Title can't be blank." };
    const number = await createIssue(repo.id, viewer.id, {
      title,
      body: body ?? "",
      ...(canWrite(access) ? properties : {}),
    });
    revalidateIssues(repo);
    return { number };
  } catch (e) {
    if (e instanceof ActionError) return { error: e.message };
    throw e;
  }
}

export async function updateIssueAction(owner: string, name: string, number: number, patch: IssuePatch): Promise<ActionResult> {
  try {
    const { repo, viewer, access } = await repoForAction(owner, name, "read");
    const issue = await getIssue(repo.id, number);
    if (!issue) return { error: "Issue not found." };
    if (!canWrite(access)) return { error: "Only collaborators can change issues. You can still comment." };
    const clean = await validate(repo, patch);
    await updateIssue(issue, clean, viewer.id);
    revalidateIssues(repo);
    return {};
  } catch (e) {
    if (e instanceof ActionError) return { error: e.message };
    throw e;
  }
}

export async function commentAction(owner: string, name: string, number: number, formData: FormData) {
  const { repo, viewer } = await repoForAction(owner, name, "read");
  const issue = await getIssue(repo.id, number);
  if (!issue) throw new ActionError("Issue not found");
  const body = String(formData.get("body") ?? "").trim().slice(0, MAX_BODY);
  if (body) await addComment(issue.id, { body, authorId: viewer.id });
  revalidateIssues(repo);
}

export async function deleteIssueAction(owner: string, name: string, number: number) {
  const { repo, viewer, access } = await repoForAction(owner, name, "read");
  const issue = await getIssue(repo.id, number);
  if (!issue) throw new ActionError("Issue not found.");
  if (issue.authorId !== viewer.id && access !== "admin") {
    throw new ActionError("Only the issue's creator and the repository owner can delete it.");
  }
  await deleteIssue(issue.id);
  revalidateIssues(repo);
  redirect(`/${repo.owner}/${repo.name}/issues`);
}

export async function createLabelAction(
  owner: string,
  name: string,
  input: { name: string; color: string },
): Promise<ActionResult & { label?: IssueLabel }> {
  try {
    const { repo } = await repoForAction(owner, name, "write");
    const label = await createLabel(repo.id, { name: String(input.name ?? ""), color: String(input.color ?? "") });
    revalidateIssues(repo);
    return { label };
  } catch (e) {
    if (e instanceof ActionError || e instanceof LabelError) return { error: e.message };
    throw e;
  }
}

/** For the labels settings form. */
export async function createLabelFormAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise<FormState> {
  const result = await createLabelAction(owner, name, {
    name: String(formData.get("name") ?? ""),
    color: String(formData.get("color") ?? ""),
  });
  return result.error ? { error: result.error } : { success: `Created label "${result.label!.name}".` };
}

export async function deleteLabelAction(owner: string, name: string, labelId: string) {
  const { repo } = await repoForAction(owner, name, "write");
  await deleteLabel(repo.id, String(labelId));
  revalidateIssues(repo);
}