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