Vectrum
Sign In
92 lines · 3.6 KB · TypeScript
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { person } from "@/lib/actor";
import { afterRefUpdated } from "@/lib/push";
import { GitError } from "@/lib/git";
import {
  createRelease,
  deleteAsset,
  deleteRelease,
  findReleaseById,
  publishRelease,
  ReleaseError,
  updateRelease,
  type ReleaseInput,
} from "@/lib/releases";
import { markPushed, type RepoRecord } from "@/lib/repos";
import { ActionError, repoForAction } from "./guard";

export type ReleaseResult = { error?: string; release?: { id: string; tag: string } };

async function releaseOrThrow(repo: RepoRecord, id: string) {
  const release = await findReleaseById(repo.id, id);
  if (!release) throw new ActionError("Release not found.");
  return release;
}

/** A tag created from the browser starts workflows just like a push of it would. */
async function afterSave(repo: RepoRecord, tag: string, createdTag: string | null, actorId: string) {
  if (createdTag) {
    await markPushed(repo.id);
    afterRefUpdated(repo, `refs/tags/${tag}`, null, createdTag, person(actorId));
  }
  revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
}

async function handle(fn: () => Promise<{ id: string; tag: string }>): Promise<ReleaseResult> {
  try {
    return { release: await fn() };
  } catch (e) {
    if (e instanceof ActionError || e instanceof ReleaseError || e instanceof GitError) return { error: e.message };
    throw e;
  }
}

/**
 * Creates a release (`releaseId` null) or saves one. The browser uploads the files afterwards, so
 * when it has files to add it saves a new release as a draft and publishes it once they're in.
 */
export async function saveReleaseAction(owner: string, name: string, releaseId: string | null, form: ReleaseInput) {
  return handle(async () => {
    const { repo, viewer } = await repoForAction(owner, name, "write");
    const input: ReleaseInput = {
      tag: String(form.tag ?? "").trim(),
      target: String(form.target ?? "").trim(),
      title: String(form.title ?? ""),
      body: String(form.body ?? ""),
      prerelease: form.prerelease === true,
      draft: form.draft === true,
    };
    const { release, createdTag } = releaseId
      ? await updateRelease(repo, await releaseOrThrow(repo, releaseId), input)
      : await createRelease(repo, viewer.id, input);
    await afterSave(repo, release.tag, createdTag, viewer.id);
    return { id: release.id, tag: release.tag };
  });
}

export async function publishReleaseAction(owner: string, name: string, releaseId: string) {
  return handle(async () => {
    const { repo, viewer } = await repoForAction(owner, name, "write");
    const { release, createdTag } = await publishRelease(repo, await releaseOrThrow(repo, releaseId));
    await afterSave(repo, release.tag, createdTag, viewer.id);
    return { id: release.id, tag: release.tag };
  });
}

export async function deleteReleaseAction(owner: string, name: string, releaseId: string) {
  const { repo } = await repoForAction(owner, name, "write");
  await deleteRelease(await releaseOrThrow(repo, releaseId));
  revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
  redirect(`/${repo.owner}/${repo.name}/releases`);
}

export async function deleteReleaseAssetAction(owner: string, name: string, releaseId: string, assetId: string) {
  const { repo } = await repoForAction(owner, name, "write");
  const release = await releaseOrThrow(repo, releaseId);
  const asset = release.assets.find((a) => a.id === assetId);
  if (asset) await deleteAsset(release, asset);
  revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
}