Vectrum
Sign In
770 lines · 33.3 KB · TypeScript
import "server-only";
import { person, type Actor } from "./actor";
import { userForSession } from "./auth";
import { describeEvent } from "./event-text";
import { listEvents as listRepoEvents, type RepoEvent } from "./events";
import { commitCount, defaultBranch, diffCommit, getCommit, GitError, listBranches, listTags, listTree, log, normalizeRepoName, objectType, readBlob, revParse, type Commit, type FileDiff } from "./git";
import { cleanRepoPath } from "./git-write";
import type { IssuePatch } from "./issue-meta";
import {
  addComment,
  createIssue,
  getIssue,
  IssueError,
  LabelError,
  listComments,
  listEvents as listIssueEvents,
  listIssues,
  listLabels,
  listMembers,
  openIssueCount,
  updateIssue,
  validateIssuePatch,
} from "./issues";
import { recentActivity, withActivity } from "./listing";
import { IMAGE_TYPES } from "./mime";
import { ProtectionError } from "./protection";
import {
  addPullComment,
  countOpenPulls,
  getPull,
  headLabel,
  listPullComments,
  listPullEvents,
  listPulls,
  listReviews,
  mergePull,
  pullCommits,
  pullDiff,
  PullError,
  pullStatus,
  submitReview,
  threadsOf,
  updatePull,
  type PullRequest,
  type PullState,
  type Review,
} from "./pulls";
import { countReleases, listReleases, releaseTitle } from "./releases";
import { cloneUrls } from "./repo";
import { canWrite, findRepo, getRepoImage, listReposByOwner, listReposForUser, repoAccess, searchRepos, visibleParent, type Access, type RepoRecord } from "./repos";
import { serverOrigin } from "./request-origin";
import { commitStatuses, getRun, listJobs, listRuns, listSteps, readJobLogs, type Run } from "./runs";
import { SIGNUPS_ENABLED } from "./signups";
import { authenticateUser, createUser, getUserByUsername, UserError, type User } from "./users";

/*
 * The JSON API of the Vectrum mobile app (apps/mobile), under /api/v1. The app signs in with a username
 * and password and gets the same session token the web UI keeps in its cookie; it sends it as a
 * bearer token. Everything here acts as that person, with the web UI's permission checks (see
 * app/actions/*), so the app can do nothing the website wouldn't let them do.
 */

export class ApiError extends Error {
  constructor(
    readonly status: number,
    message: string,
  ) {
    super(message);
  }
}

const isUserError = (e: unknown) =>
  e instanceof IssueError || e instanceof LabelError || e instanceof PullError || e instanceof ProtectionError || e instanceof GitError;

/** Turns what a handler threw into a JSON answer: user errors become 4xx, anything else is rethrown. */
export function errorResponse(e: unknown) {
  if (e instanceof ApiError) return json({ error: e.message }, e.status);
  if (isUserError(e)) return json({ error: (e as Error).message }, 422);
  throw e;
}

export const json = (body: unknown, status = 200) => Response.json(body, { status, headers: { "Cache-Control": "no-store" } });

// ---------------------------------------------------------------------------
// Who is asking

/** The signed-in person, null without credentials. A bearer token that isn't a valid session is a 401. */
export async function apiViewer(request: Request): Promise<User | null> {
  const header = request.headers.get("authorization") ?? "";
  if (!/^Bearer\s+/i.test(header)) return null;
  const user = await userForSession(header.replace(/^Bearer\s+/i, "").trim());
  if (!user) throw new ApiError(401, "Your session has expired. Sign in again.");
  return user;
}

function requireUser(viewer: User | null) {
  if (!viewer) throw new ApiError(401, "Sign in to do that.");
  return viewer;
}

const MAX_BODY = 65_536;

/** Largest request body read: issue and comment bodies are capped at 64 KB, so this leaves room for the rest. */
const MAX_REQUEST_BYTES = 256 * 1024;

async function readJson(request: Request): Promise<Record<string, unknown>> {
  if (Number(request.headers.get("content-length") ?? 0) > MAX_REQUEST_BYTES) throw new ApiError(413, "That request is too large.");
  // Read with a cap too: the length header is optional.
  const reader = request.body?.getReader();
  const chunks: Uint8Array[] = [];
  let size = 0;
  for (;;) {
    const part = reader ? await reader.read() : { done: true as const, value: undefined };
    if (part.done) break;
    size += part.value.byteLength;
    if (size > MAX_REQUEST_BYTES) {
      await reader?.cancel();
      throw new ApiError(413, "That request is too large.");
    }
    chunks.push(part.value);
  }
  const raw = Buffer.concat(chunks).toString("utf8");
  const body = (() => {
    try {
      return JSON.parse(raw) as unknown;
    } catch {
      return null;
    }
  })();
  if (!body || typeof body !== "object" || Array.isArray(body)) throw new ApiError(400, "Expected a JSON object.");
  return body as Record<string, unknown>;
}

const text = (v: unknown, max = MAX_BODY) => (typeof v === "string" ? v.slice(0, max) : "");

/** What the app's setup shows about a server before signing in: that it's a Vectrum, and whether it takes sign-ups. */
export function serverInfo() {
  return { name: "Vectrum", api: 1, signups: SIGNUPS_ENABLED };
}

/** Creates an account, as the website's sign-up form does, and signs it in. */
export async function signUp(request: Request) {
  if (!SIGNUPS_ENABLED) throw new ApiError(403, "Sign-ups are closed on this server. Ask the person who runs it for an account.");
  const body = await readJson(request);
  const password = text(body.password, 200);
  try {
    const user = await createUser({ username: text(body.username, 100), email: text(body.email, 200), password, name: text(body.name, 100) });
    const auth = await authenticateUser(user.email, password);
    if (!auth) throw new ApiError(500, "The account was created, but signing in failed. Try signing in.");
    return { token: auth.token, user: userJson(auth.user) };
  } catch (e) {
    if (e instanceof UserError) throw new ApiError(422, e.message);
    throw e;
  }
}

export async function signIn(request: Request) {
  const body = await readJson(request);
  const auth = await authenticateUser(text(body.login, 200), text(body.password, 200));
  if (!auth) throw new ApiError(401, "Incorrect username or password.");
  return { token: auth.token, user: userJson(auth.user) };
}

// ---------------------------------------------------------------------------
// Shapes

const userJson = (u: User) => ({ id: u.id, username: u.username, name: u.name, email: u.email, bio: u.bio, createdAt: u.createdAt });

type Listed = RepoRecord & { updatedAt?: number; empty?: boolean; parent?: RepoRecord["forkOf"] };

function repoJson(r: Listed) {
  return {
    id: r.id,
    owner: r.owner,
    name: r.name,
    description: r.description,
    visibility: r.visibility,
    // The repository a fork came from, when the viewer may see it.
    forkOf: r.parent ? { owner: r.parent.owner, name: r.parent.name } : null,
    isFork: !!r.forkOf,
    hasImage: r.imageVersion !== null,
    imageVersion: r.imageVersion,
    createdAt: r.createdAt,
    updatedAt: r.updatedAt ?? r.pushedAt ?? r.createdAt,
    empty: r.empty ?? false,
  };
}

const commitJson = (c: Commit) => ({
  sha: c.sha,
  parents: c.parents,
  authorName: c.authorName,
  authorEmail: c.authorEmail,
  committerName: c.committerName,
  date: c.date,
  subject: c.subject,
  body: c.body,
  // Commits an agent made for its person name the agent as committer.
  agent: /^agent\+/.test(c.committerEmail) ? c.committerName : null,
});

/** Longest diff the app gets: enough to read on a phone, without sending megabytes. */
const MAX_DIFF_LINES = 8000;
const MAX_FILE_LINES = 1500;

function diffJson(files: FileDiff[]) {
  let budget = MAX_DIFF_LINES;
  return files.map((f) => {
    const keep = Math.max(0, Math.min(f.lines.length, MAX_FILE_LINES, budget));
    budget -= keep;
    return {
      oldPath: f.oldPath,
      newPath: f.newPath,
      status: f.status,
      binary: f.binary,
      additions: f.additions,
      deletions: f.deletions,
      truncated: keep < f.lines.length,
      lines: f.lines.slice(0, keep).map((l) => ({ kind: l.kind, text: l.text, oldNo: l.oldNo ?? null, newNo: l.newNo ?? null })),
    };
  });
}

const pullJson = (p: PullRequest) => ({
  number: p.number,
  title: p.title,
  body: p.body,
  author: p.author,
  agent: p.agent?.name ?? null,
  head: p.head,
  headLabel: headLabel(p),
  base: p.base,
  headSha: p.headSha,
  state: p.state,
  draft: p.draft,
  mergeSha: p.mergeSha,
  mergeMethod: p.mergeMethod,
  mergedBy: p.mergedBy,
  linkedIssues: p.linkedIssues,
  commentCount: p.commentCount,
  createdAt: p.createdAt,
  updatedAt: p.updatedAt,
  closedAt: p.closedAt,
  mergedAt: p.mergedAt,
});

const runJson = (r: Run) => ({
  number: r.number,
  workflowName: r.workflowName,
  workflowPath: r.workflowPath,
  event: r.event,
  ref: r.ref.replace(/^refs\/(heads|tags)\//, ""),
  sha: r.sha,
  title: r.title,
  actor: r.actor,
  agent: r.agentName,
  status: r.status,
  error: r.error,
  createdAt: r.createdAt,
  startedAt: r.startedAt,
  finishedAt: r.finishedAt,
});

function eventJson(e: RepoEvent, repo: RepoRecord | undefined) {
  const number = /^#(\d+)$/.exec(e.subject)?.[1];
  const p = e.payload as Record<string, unknown>;
  const isPull = !!p.pull_request || e.kind.startsWith("pull_request");
  const run = (p.workflow_run ?? {}) as Record<string, unknown>;
  return {
    id: e.id,
    kind: e.kind,
    action: e.action,
    actor: e.actor,
    agent: e.agent?.name ?? null,
    text: describeEvent(e),
    createdAt: e.createdAt,
    repo: repo ? { owner: repo.owner, name: repo.name } : null,
    // What the event is about, for the app to open it.
    target: number
      ? { type: isPull ? "pull" : "issue", number: Number(number) }
      : e.kind === "push" && typeof p.after === "string" && p.after && !p.deleted
        ? { type: "commit", sha: p.after }
        : e.kind === "workflow_run" && typeof run.number === "number"
          ? { type: "run", number: run.number }
          : null,
  };
}

// ---------------------------------------------------------------------------
// Home, explore, people

export async function home(viewer: User | null) {
  const user = requireUser(viewer);
  const mine = await withActivity(await listReposForUser(user.id), user.id);
  const byId = new Map(mine.map((r) => [r.id, r]));
  const [events, activity, discover] = await Promise.all([
    listRepoEvents({ repoIds: mine.map((r) => r.id), limit: 40 }),
    recentActivity(mine, 10),
    searchRepos("", user.id, 30).then((rs) => withActivity(rs.filter((r) => r.ownerId !== user.id && r.visibility === "public").slice(0, 6), user.id)),
  ]);
  const counts = await Promise.all(mine.slice(0, 50).map(async (r) => ({ issues: await openIssueCount(r.id), pulls: await countOpenPulls(r.id) })));
  return {
    user: userJson(user),
    repos: mine.map(repoJson),
    openIssues: counts.reduce((n, c) => n + c.issues, 0),
    openPulls: counts.reduce((n, c) => n + c.pulls, 0),
    events: events.reverse().map((e) => eventJson(e, byId.get(e.repoId))),
    commits: activity.map(({ repo, commit }) => ({ repo: { owner: repo.owner, name: repo.name }, commit: commitJson(commit) })),
    discover: discover.map(repoJson),
  };
}

export async function explore(viewer: User | null, query: string) {
  const repos = await withActivity(await searchRepos(query.slice(0, 100), viewer?.id ?? null, 50), viewer?.id);
  return { repos: repos.map(repoJson) };
}

export async function profile(viewer: User | null, username: string) {
  const user = await getUserByUsername(username);
  if (!user) throw new ApiError(404, "There's no such user.");
  const repos = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null), viewer?.id);
  return { user: { ...userJson(user), email: viewer?.id === user.id ? user.email : "" }, repos: repos.map(repoJson) };
}

// ---------------------------------------------------------------------------
// Repositories

type Opened = { repo: RepoRecord; access: Exclude<Access, null> };

async function openRepo(viewer: User | null, owner: string, name: string, need: "read" | "write" = "read"): Promise<Opened> {
  const repo = await findRepo(owner, normalizeRepoName(name));
  const access = repo ? await repoAccess(repo, viewer?.id) : null;
  // Repositories the viewer can't see are "not found", so private ones don't reveal they exist.
  if (!repo || !access) throw new ApiError(404, "There's no such repository, or you can't see it.");
  if (need === "write" && !canWrite(access)) throw new ApiError(403, "You don't have push access to this repository.");
  return { repo, access };
}

/** A branch, tag or commit the app named, or the default branch. */
async function resolveRef(repo: RepoRecord, ref: string | null) {
  const name = ref || (await defaultBranch(repo.dir));
  if (!name) return null;
  // Like the website: branch and tag names, commit SHAs and HEAD, not git's revision syntax (main^{/text}, :/text, @{1}).
  if (name.length > 255 || name.startsWith("-") || /[\s~^:?*[\\]|\.\.|@\{/.test(name)) throw new ApiError(400, "That isn't a valid branch, tag or commit.");
  const sha =
    name === "HEAD"
      ? await revParse(repo.dir, "HEAD")
      : ((await revParse(repo.dir, `refs/heads/${name}`)) ??
        (await revParse(repo.dir, `refs/tags/${name}`)) ??
        (/^[0-9a-f]{4,40}$/i.test(name) ? await revParse(repo.dir, name) : null));
  if (!sha) throw new ApiError(404, `There's no branch, tag or commit named ${name}.`);
  return { ref: name, sha };
}

function cleanPath(input: string | null) {
  const trimmed = (input ?? "").replace(/^\/+|\/+$/g, "");
  if (!trimmed) return "";
  const path = cleanRepoPath(trimmed);
  if (!path) throw new ApiError(400, "That isn't a valid path.");
  return path;
}

const README = /^readme(\.md|\.markdown|\.txt)?$/i;

export async function repository(viewer: User | null, owner: string, name: string) {
  const { repo, access } = await openRepo(viewer, owner, name);
  const branch = await defaultBranch(repo.dir);
  const [parent, urls, branches, tags, issues, pulls, releases] = await Promise.all([
    visibleParent(repo, viewer?.id),
    cloneUrls(repo),
    listBranches(repo.dir),
    listTags(repo.dir),
    openIssueCount(repo.id),
    countOpenPulls(repo.id),
    countReleases(repo.id),
  ]);
  let readme: { path: string; text: string } | null = null;
  let entries: Awaited<ReturnType<typeof listTree>> = [];
  let lastCommit: Commit | null = null;
  let commits = 0;
  if (branch) {
    const ref = `refs/heads/${branch}`;
    [entries, lastCommit, commits] = await Promise.all([listTree(repo.dir, ref, ""), log(repo.dir, ref, { limit: 1 }).then((c) => c[0] ?? null), commitCount(repo.dir, ref)]);
    const file = entries.find((e) => e.type === "blob" && README.test(e.name));
    if (file) {
      const blob = await readBlob(repo.dir, ref, file.path);
      if (!blob.binary && !blob.tooLarge) readme = { path: file.path, text: blob.content.toString("utf8").slice(0, 200_000) };
    }
  }
  return {
    repo: repoJson({ ...repo, parent, updatedAt: lastCommit?.date ?? repo.pushedAt ?? repo.createdAt, empty: !branch }),
    access,
    canWrite: canWrite(access),
    defaultBranch: branch,
    branches: branches.map((b) => b.name),
    tags: tags.slice(0, 100),
    cloneUrls: urls,
    counts: { issues, pulls, releases, commits, branches: branches.length },
    lastCommit: lastCommit && commitJson(lastCommit),
    entries: entries.map(entryJson),
    readme,
    wikiEnabled: repo.wikiEnabled,
  };
}

const entryJson = (e: Awaited<ReturnType<typeof listTree>>[number]) => ({ name: e.name, path: e.path, type: e.type, size: e.size });

export async function repoImage(viewer: User | null, owner: string, name: string) {
  const { repo } = await openRepo(viewer, owner, name);
  const image = await getRepoImage(repo.id);
  if (!image) throw new ApiError(404, "This repository has no image.");
  return new Response(new Uint8Array(image.data), {
    headers: { "Content-Type": image.contentType, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, max-age=31536000, immutable" },
  });
}

export async function tree(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
  const { repo } = await openRepo(viewer, owner, name);
  const resolved = await resolveRef(repo, params.get("ref"));
  if (!resolved) return { ref: null, path: "", entries: [] };
  const path = cleanPath(params.get("path"));
  if (path && (await objectType(repo.dir, resolved.sha, path)) !== "tree") throw new ApiError(404, "There's no such folder.");
  return { ref: resolved.ref, path, entries: (await listTree(repo.dir, resolved.sha, path)).map(entryJson) };
}

/** Largest file the app shows as text. */
const MAX_TEXT_BYTES = 1024 * 1024;

export async function blob(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
  const { repo } = await openRepo(viewer, owner, name);
  const resolved = await resolveRef(repo, params.get("ref"));
  const path = cleanPath(params.get("path"));
  if (!resolved || !path || (await objectType(repo.dir, resolved.sha, path)) !== "blob") throw new ApiError(404, "There's no such file.");
  const file = await readBlob(repo.dir, resolved.sha, path);
  const tooLarge = file.tooLarge || file.size > MAX_TEXT_BYTES;
  const showText = !file.binary && !tooLarge;
  const last = await log(repo.dir, resolved.sha, { path, limit: 1 });
  return {
    ref: resolved.ref,
    path,
    size: file.size,
    binary: file.binary,
    tooLarge,
    image: isRasterImage(path) && !file.tooLarge,
    text: showText ? file.content.toString("utf8") : null,
    lastCommit: last[0] ? commitJson(last[0]) : null,
  };
}

/** Images the app shows as pictures. SVGs are left out: they can carry scripts, and Android can't draw them. */
const imageType = (path: string) => {
  const type = IMAGE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""];
  return type && type !== "image/svg+xml" ? type : null;
};
const isRasterImage = (path: string) => imageType(path) !== null;

/** A file's bytes, for images in the code browser and READMEs. */
export async function raw(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
  const { repo } = await openRepo(viewer, owner, name);
  const resolved = await resolveRef(repo, params.get("ref"));
  const path = cleanPath(params.get("path"));
  if (!resolved || !path || (await objectType(repo.dir, resolved.sha, path)) !== "blob") throw new ApiError(404, "There's no such file.");
  const file = await readBlob(repo.dir, resolved.sha, path);
  if (file.tooLarge) throw new ApiError(413, "This file is too large to show.");
  // Only raster images are sent as what they are; anything else is plain bytes, so nothing scripted runs.
  return new Response(new Uint8Array(file.content), {
    headers: {
      "Content-Type": imageType(path) ?? "application/octet-stream",
      "X-Content-Type-Options": "nosniff",
      "Content-Security-Policy": "default-src 'none'; sandbox",
      "Cache-Control": "private, no-cache",
    },
  });
}

export async function commits(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
  const { repo } = await openRepo(viewer, owner, name);
  const resolved = await resolveRef(repo, params.get("ref"));
  if (!resolved) return { ref: null, commits: [], hasMore: false };
  const path = cleanPath(params.get("path"));
  const page = Math.max(1, Math.min(1000, Number(params.get("page")) || 1));
  const perPage = 30;
  const list = await log(repo.dir, resolved.sha, { path: path || undefined, limit: perPage + 1, skip: (page - 1) * perPage });
  const shown = list.slice(0, perPage);
  const statuses = await commitStatuses(repo.id, shown.map((c) => c.sha));
  return {
    ref: resolved.ref,
    commits: shown.map((c) => ({ ...commitJson(c), status: statuses.get(c.sha) ?? null })),
    hasMore: list.length > perPage,
  };
}

export async function commit(viewer: User | null, owner: string, name: string, sha: string) {
  const { repo } = await openRepo(viewer, owner, name);
  const found = await getCommit(repo.dir, sha);
  if (!found) throw new ApiError(404, "There's no such commit.");
  const files = await diffCommit(repo.dir, found);
  return { commit: commitJson(found), files: diffJson(files) };
}

export async function branches(viewer: User | null, owner: string, name: string) {
  const { repo } = await openRepo(viewer, owner, name);
  const [list, branch] = await Promise.all([listBranches(repo.dir), defaultBranch(repo.dir)]);
  return {
    defaultBranch: branch,
    branches: list.map((b) => ({ name: b.name, sha: b.sha, date: b.date, subject: b.subject, isDefault: b.name === branch })),
  };
}

export async function repoEvents(viewer: User | null, owner: string, name: string) {
  const { repo } = await openRepo(viewer, owner, name);
  const events = await listRepoEvents({ repoIds: [repo.id], limit: 50 });
  return { events: events.reverse().map((e) => eventJson(e, repo)) };
}

// ---------------------------------------------------------------------------
// Issues
// Anyone who can read a repository may open issues and comment; changing one needs push access.

export async function issues(viewer: User | null, owner: string, name: string) {
  const { repo, access } = await openRepo(viewer, owner, name);
  const [list, labels, members] = await Promise.all([listIssues(repo.id), listLabels(repo.id), listMembers(repo.id, repo.ownerId)]);
  return { canWrite: canWrite(access), issues: list, labels, members };
}

export async function issue(viewer: User | null, owner: string, name: string, number: number) {
  const { repo, access } = await openRepo(viewer, owner, name);
  const found = await getIssue(repo.id, number);
  if (!found) throw new ApiError(404, "There's no such issue.");
  const [comments, events, labels, members] = await Promise.all([listComments(found.id), listIssueEvents(found.id), listLabels(repo.id), listMembers(repo.id, repo.ownerId)]);
  return { canWrite: canWrite(access), issue: found, comments, events, labels, members };
}

export async function openIssue(request: Request, viewer: User | null, owner: string, name: string) {
  const user = requireUser(viewer);
  const { repo, access } = await openRepo(user, owner, name);
  const body = await readJson(request);
  const input = issuePatchFrom(body);
  const { title, body: description, ...properties } = await validateIssuePatch(repo, { ...input, title: text(body.title, 300), body: text(body.body), sortOrder: undefined });
  if (!title) throw new ApiError(422, "Title can't be blank.");
  // People without push access can open issues, but not triage them.
  const number = await createIssue(repo, person(user.id), { title, body: description ?? "", ...(canWrite(access) ? properties : {}) });
  return { number };
}

/** The issue fields a request changes. A field of the wrong type is refused, never read as "clear it". */
function issuePatchFrom(body: Record<string, unknown>): IssuePatch {
  const patch: IssuePatch = {};
  const wrong = (field: string): never => {
    throw new ApiError(422, `${field} has the wrong type.`);
  };
  const has = (field: string) => field in body && body[field] !== undefined;
  if (has("title")) patch.title = typeof body.title === "string" ? body.title.slice(0, 300) : wrong("title");
  if (has("body")) patch.body = typeof body.body === "string" ? body.body.slice(0, MAX_BODY) : wrong("body");
  if (has("status")) patch.status = typeof body.status === "string" ? (body.status as IssuePatch["status"]) : wrong("status");
  if (has("priority")) patch.priority = typeof body.priority === "number" ? body.priority : wrong("priority");
  if (has("assigneeId")) patch.assigneeId = body.assigneeId === null || typeof body.assigneeId === "string" ? body.assigneeId : wrong("assigneeId");
  if (has("estimate")) patch.estimate = body.estimate === null || typeof body.estimate === "number" ? body.estimate : wrong("estimate");
  if (has("dueDate")) patch.dueDate = body.dueDate === null || typeof body.dueDate === "string" ? body.dueDate : wrong("dueDate");
  if (has("labelIds")) {
    const ids = body.labelIds;
    patch.labelIds = Array.isArray(ids) && ids.every((id) => typeof id === "string") ? (ids as string[]) : wrong("labelIds");
  }
  return patch;
}

export async function changeIssue(request: Request, viewer: User | null, owner: string, name: string, number: number) {
  const user = requireUser(viewer);
  const { repo, access } = await openRepo(user, owner, name);
  const found = await getIssue(repo.id, number);
  if (!found) throw new ApiError(404, "There's no such issue.");
  if (!canWrite(access)) throw new ApiError(403, "Only collaborators can change issues. You can still comment.");
  const patch = await validateIssuePatch(repo, issuePatchFrom(await readJson(request)));
  if (patch.title !== undefined && !patch.title.trim()) throw new ApiError(422, "Title can't be blank.");
  await updateIssue(repo, found, patch, person(user.id));
  return issue(user, owner, name, number);
}

export async function commentOnIssue(request: Request, viewer: User | null, owner: string, name: string, number: number) {
  const user = requireUser(viewer);
  const { repo } = await openRepo(user, owner, name);
  const found = await getIssue(repo.id, number);
  if (!found) throw new ApiError(404, "There's no such issue.");
  const body = text((await readJson(request)).body).trim();
  if (!body) throw new ApiError(422, "Write something first.");
  await addComment(repo, found, person(user.id), body);
  return issue(user, owner, name, number);
}

// ---------------------------------------------------------------------------
// Pull requests
// As on the website: anyone who can read may comment; the author and collaborators may close and
// reopen; approving, requesting changes and merging need push access (and the base branch's rules).

export async function pulls(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
  const { repo, access } = await openRepo(viewer, owner, name);
  const state = params.get("state");
  const valid: (PullState | "all")[] = ["open", "closed", "merged", "all"];
  const { pulls: list } = await listPulls(repo.id, { state: valid.includes(state as PullState) ? (state as PullState | "all") : "open", perPage: 100 });
  const counts = { open: await countOpenPulls(repo.id) };
  return { canWrite: canWrite(access), counts, pulls: list.map(pullJson) };
}

async function openPull(viewer: User | null, owner: string, name: string, number: number, need: "read" | "write" = "read") {
  const opened = await openRepo(viewer, owner, name, need);
  const pr = await getPull(opened.repo.id, number);
  if (!pr) throw new ApiError(404, "There's no such pull request.");
  return { ...opened, pr };
}

export async function pull(viewer: User | null, owner: string, name: string, number: number) {
  const { repo, access, pr } = await openPull(viewer, owner, name, number);
  const actor: Actor | null = viewer ? person(viewer.id) : null;
  const [status, reviews, comments, events, commitList] = await Promise.all([
    pullStatus(repo, pr, actor),
    listReviews(pr.id),
    listPullComments(pr.id),
    listPullEvents(pr.id),
    pullCommits(repo, pr),
  ]);
  const isAuthor = viewer?.id === pr.authorId;
  return {
    pull: pullJson(pr),
    canWrite: canWrite(access),
    canEdit: canWrite(access) || isAuthor,
    canMerge: canWrite(access) && pr.state === "open",
    status: {
      headSha: status.headSha,
      headExists: status.headExists,
      ahead: status.ahead,
      behind: status.behind,
      conflicts: status.conflicts,
      checks: status.checks,
      runs: status.runs.map(runJson),
      approvals: status.approvals,
      changesRequestedBy: status.changesRequestedBy,
      requirements: status.requirements,
      mergeable: status.mergeable,
    },
    reviews: reviews.map((r: Review) => ({ id: r.id, author: r.author, agent: r.agent, state: r.state, body: r.body, commitSha: r.commitSha, createdAt: r.createdAt })),
    comments: comments.filter((c) => !c.path).map((c) => ({ id: c.id, author: c.author, agent: c.agent, body: c.body, createdAt: c.createdAt })),
    threads: threadsOf(comments, status.headSha).map((t) => ({
      path: t.root.path,
      line: t.root.line,
      side: t.root.side,
      snippet: t.root.snippet,
      resolved: t.root.resolved,
      outdated: t.outdated,
      comments: [t.root, ...t.replies].map((c) => ({ id: c.id, author: c.author, agent: c.agent, body: c.body, createdAt: c.createdAt })),
      createdAt: t.root.createdAt,
    })),
    events: events.map((e) => ({ id: e.id, actor: e.actor, agent: e.agent, kind: e.kind, data: e.data, createdAt: e.createdAt })),
    commits: commitList.map(commitJson),
  };
}

export async function pullFiles(viewer: User | null, owner: string, name: string, number: number) {
  const { repo, pr } = await openPull(viewer, owner, name, number);
  return { files: diffJson(await pullDiff(repo, pr)) };
}

export async function commentOnPull(request: Request, viewer: User | null, owner: string, name: string, number: number) {
  const user = requireUser(viewer);
  const { repo, pr } = await openPull(user, owner, name, number);
  const body = text((await readJson(request)).body).trim();
  if (!body) throw new ApiError(422, "Write something first.");
  await addPullComment(repo, pr, person(user.id), body);
  return pull(user, owner, name, number);
}

export async function reviewPull(request: Request, viewer: User | null, owner: string, name: string, number: number) {
  const user = requireUser(viewer);
  const { repo, access, pr } = await openPull(user, owner, name, number);
  const input = await readJson(request);
  const state = input.state as Review["state"];
  if (!["approved", "changes_requested", "commented"].includes(state)) throw new ApiError(422, "Choose what the review says.");
  if (state !== "commented" && !canWrite(access)) throw new ApiError(403, "Only people with push access can approve or request changes.");
  await submitReview(repo, pr, person(user.id), { state, body: text(input.body).trim(), comments: [] });
  return pull(user, owner, name, number);
}

export async function changePull(request: Request, viewer: User | null, owner: string, name: string, number: number) {
  const user = requireUser(viewer);
  const { repo, access, pr } = await openPull(user, owner, name, number);
  if (!canWrite(access) && pr.authorId !== user.id) throw new ApiError(403, "Only collaborators and its author can change a pull request.");
  const input = await readJson(request);
  const patch: { state?: "open" | "closed"; draft?: boolean; title?: string } = {};
  if (input.state === "open" || input.state === "closed") patch.state = input.state;
  if (typeof input.draft === "boolean") patch.draft = input.draft;
  if (typeof input.title === "string") patch.title = input.title.slice(0, 300);
  await updatePull(repo, pr, person(user.id), patch);
  return pull(user, owner, name, number);
}

export async function mergePullRequest(request: Request, viewer: User | null, owner: string, name: string, number: number) {
  const user = requireUser(viewer);
  const { repo, pr } = await openPull(user, owner, name, number, "write");
  const input = await readJson(request);
  const method = input.method === "squash" ? "squash" : input.method === "merge" || input.method === undefined ? "merge" : null;
  if (!method) throw new ApiError(422, "Unknown merge method.");
  await mergePull(repo, pr, person(user.id), {
    method,
    deleteBranch: input.deleteBranch === true,
    expectedHeadSha: typeof input.headSha === "string" && input.headSha ? input.headSha : undefined,
  });
  return pull(user, owner, name, number);
}

// ---------------------------------------------------------------------------
// Actions and releases

export async function runs(viewer: User | null, owner: string, name: string, params: URLSearchParams) {
  const { repo } = await openRepo(viewer, owner, name);
  const page = Math.max(1, Math.min(1000, Number(params.get("page")) || 1));
  const list = await listRuns(repo.id, { limit: 30, offset: (page - 1) * 30 });
  return { runs: list.map(runJson), hasMore: list.length === 30 };
}

/** The end of a long log is what tells why it failed. */
const MAX_LOG_CHARS = 60_000;

export async function run(viewer: User | null, owner: string, name: string, number: number) {
  const { repo } = await openRepo(viewer, owner, name);
  const found = await getRun(repo.id, number);
  if (!found) throw new ApiError(404, "There's no such workflow run.");
  const jobs = await listJobs(found.id);
  const detailed = await Promise.all(
    jobs.map(async (j) => {
      const [steps, logs] = await Promise.all([listSteps(j.id), readJobLogs(j.id)]);
      return {
        key: j.key,
        name: j.name,
        status: j.status,
        runner: j.runnerName,
        startedAt: j.startedAt,
        finishedAt: j.finishedAt,
        steps: steps.map((s) => {
          const log = logs.get(s.index) ?? "";
          return { ...s, log: log.length > MAX_LOG_CHARS ? log.slice(-MAX_LOG_CHARS) : log, logTruncated: log.length > MAX_LOG_CHARS };
        }),
      };
    }),
  );
  return { run: runJson(found), jobs: detailed };
}

export async function releases(viewer: User | null, owner: string, name: string) {
  const { repo, access } = await openRepo(viewer, owner, name);
  const origin = await serverOrigin();
  const { releases: list } = await listReleases(repo.id, { drafts: canWrite(access), page: 1, perPage: 50 });
  return {
    releases: list.map((r) => ({
      tag: r.tag,
      title: releaseTitle(r),
      body: r.body,
      draft: r.draft,
      prerelease: r.prerelease,
      author: r.author,
      createdAt: r.createdAt,
      publishedAt: r.publishedAt,
      assets: r.assets.map((a) => ({
        name: a.name,
        size: a.size,
        downloads: a.downloads,
        url: `${origin}/${repo.owner}/${repo.name}/releases/download/${encodeURIComponent(r.tag)}/${encodeURIComponent(a.name)}`,
      })),
    })),
  };
}