Vectrum
Sign In
325 lines · 12.7 KB · TypeScript
import { db, filter, findFirst, isUniqueViolation, orNull, withLock, type RecordModel } from "./db";
import {
  ESTIMATES,
  isClosedStatus,
  isValidDay,
  STATUS_IDS,
  type Issue,
  type IssueLabel,
  type IssuePatch,
  type IssueSummary,
  type Member,
  type NewIssueInput,
} from "./issue-meta";
import { getUserById } from "./users";

export type { Issue, IssueLabel, IssueSummary, Member };

export type Comment = { id: string; author: string; body: string; createdAt: number };
export type IssueEvent = {
  id: string;
  actor: string;
  field: string;
  oldValue: string | null;
  newValue: string | null;
  createdAt: number;
};

const byName = (a: { name: string }, b: { name: string }) => a.name.toLowerCase().localeCompare(b.name.toLowerCase());

const toLabel = (r: RecordModel): IssueLabel => ({ id: r.id, name: r.name, color: r.color });

const EXPAND = "author,assignee,labels";
// Lists leave out the descriptions.
const SUMMARY_FIELDS = [
  "id,number,title,author,state,status,priority,assignee,estimate,due_date,sort_order,labels,comment_count,created_at,updated_at",
  "expand.author.username,expand.assignee.username,expand.labels.id,expand.labels.name,expand.labels.color",
].join(",");

const toSummary = (r: RecordModel): IssueSummary => ({
  id: r.id,
  number: r.number,
  title: r.title,
  authorId: r.author,
  author: r.expand?.author?.username ?? "",
  status: r.status,
  priority: r.priority,
  assigneeId: orNull(r.assignee),
  assignee: r.expand?.assignee?.username ?? null,
  estimate: orNull(r.estimate),
  dueDate: orNull(r.due_date),
  sortOrder: r.sort_order,
  labels: ((r.expand?.labels ?? []) as RecordModel[]).map(toLabel).sort(byName),
  createdAt: r.created_at,
  updatedAt: r.updated_at,
  commentCount: r.comment_count,
});

const issues = async () => (await db()).collection("issues");

export const MAX_ISSUE_BODY = 65_536;

export class IssueError extends Error {}

/**
 * Validates untrusted issue properties (from the browser or an agent). Only the fields that
 * are present are checked; anything invalid throws.
 */
export async function validateIssuePatch(repo: { id: string; ownerId: string }, patch: IssuePatch): Promise<IssuePatch> {
  const out: IssuePatch = {};
  if (patch.title !== undefined) {
    const title = String(patch.title).trim().slice(0, 256);
    if (!title) throw new IssueError("Title can't be blank.");
    out.title = title;
  }
  if (patch.body !== undefined) out.body = String(patch.body).trim().slice(0, MAX_ISSUE_BODY);
  if (patch.status !== undefined) {
    if (!STATUS_IDS.includes(patch.status)) throw new IssueError("Unknown status.");
    out.status = patch.status;
  }
  if (patch.priority !== undefined) {
    if (!Number.isInteger(patch.priority) || patch.priority < 0 || patch.priority > 4) throw new IssueError("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 IssueError("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 IssueError("Unknown estimate.");
    out.estimate = patch.estimate;
  }
  if (patch.dueDate !== undefined) {
    if (patch.dueDate !== null && !isValidDay(String(patch.dueDate))) throw new IssueError("Invalid due date.");
    out.dueDate = patch.dueDate;
  }
  if (patch.labelIds !== undefined) {
    if (!Array.isArray(patch.labelIds)) throw new IssueError("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 IssueError("Unknown label.");
    out.labelIds = ids;
  }
  if (patch.sortOrder !== undefined) {
    if (typeof patch.sortOrder !== "number" || !Number.isFinite(patch.sortOrder)) throw new IssueError("Invalid order.");
    out.sortOrder = patch.sortOrder;
  }
  return out;
}

/** Every issue in a repository, without descriptions. Filtering and grouping happen in the browser. */
export async function listIssues(repoId: string) {
  const rows = await (await issues()).getFullList({
    filter: filter("repo = {:repoId}", { repoId }),
    sort: "-number",
    expand: EXPAND,
    fields: SUMMARY_FIELDS,
  });
  return rows.map(toSummary);
}

export async function openIssueCount(repoId: string) {
  return (await (await issues()).getList(1, 1, { filter: filter("repo = {:repoId} && state = 'open'", { repoId }), fields: "id" })).totalItems;
}

export async function getIssue(repoId: string, number: number): Promise<Issue | null> {
  if (!Number.isInteger(number)) return null;
  const row = await findFirst("issues", filter("repo = {:repoId} && number = {:number}", { repoId, number }), { expand: EXPAND });
  if (!row) return null;
  return { ...toSummary(row), body: row.body, state: row.state };
}

export async function listComments(issueId: string): Promise<Comment[]> {
  const rows = await (await db())
    .collection("issue_comments")
    .getFullList({ filter: filter("issue = {:issueId}", { issueId }), sort: "created_at,@rowid", expand: "author" });
  return rows.map((r) => ({ id: r.id, author: r.expand?.author?.username ?? "", body: r.body, createdAt: r.created_at }));
}

export async function listEvents(issueId: string): Promise<IssueEvent[]> {
  const rows = await (await db())
    .collection("issue_events")
    .getFullList({ filter: filter("issue = {:issueId}", { issueId }), sort: "created_at,@rowid", expand: "actor" });
  return rows.map((r) => ({
    id: r.id,
    actor: r.expand?.actor?.username ?? "",
    field: r.field,
    oldValue: orNull(r.old_value),
    newValue: orNull(r.new_value),
    createdAt: r.created_at,
  }));
}

/** People who can be assigned: the owner and the collaborators. */
export async function listMembers(repoId: string, ownerId: string): Promise<Member[]> {
  const rows = await (await db()).collection("users").getFullList({
    filter: filter("id = {:ownerId} || collaborators_via_user.repo ?= {:repoId}", { ownerId, repoId }),
    sort: "username",
    fields: "id,username,name",
  });
  return rows.map((r) => ({ id: r.id, username: r.username, name: r.name }));
}

export async function createIssue(repoId: string, authorId: string, input: NewIssueInput) {
  // Numbers are handed out one at a time; the unique index catches anything that slips past.
  return withLock(`issues:${repoId}`, async () => {
    const last = await findFirst("issues", filter("repo = {:repoId}", { repoId }), { sort: "-number", fields: "number" });
    const number = (last?.number ?? 0) + 1;
    const now = Date.now();
    const status = input.status ?? "todo";
    await (await issues()).create({
      repo: repoId,
      number,
      title: input.title,
      body: input.body,
      author: authorId,
      state: isClosedStatus(status) ? "closed" : "open",
      status,
      priority: input.priority ?? 0,
      assignee: input.assigneeId ?? "",
      estimate: input.estimate ?? 0,
      due_date: input.dueDate ?? "",
      sort_order: -now,
      labels: input.labelIds ?? [],
      created_at: now,
      updated_at: now,
    });
    return number;
  });
}

export async function addComment(issueId: string, data: { body: string; authorId: string }) {
  const now = Date.now();
  const batch = (await db()).createBatch();
  batch.collection("issue_comments").create({ issue: issueId, author: data.authorId, body: data.body, created_at: now });
  batch.collection("issues").update(issueId, { updated_at: now, "comment_count+": 1 });
  await batch.send();
}

async function labelNames(ids: string[]) {
  if (ids.length === 0) return new Map<string, string>();
  const rows = await (await db()).collection("labels").getFullList({
    filter: ids.map((id, i) => filter(`id = {:id${i}}`, { [`id${i}`]: id })).join(" || "),
    fields: "id,name",
  });
  return new Map(rows.map((r) => [r.id, r.name as string]));
}

/**
 * Applies an already-validated patch and records what changed in the activity
 * feed. Reordering alone doesn't count as an update.
 */
export async function updateIssue(issue: Issue, patch: IssuePatch, actorId: string) {
  const now = Date.now();
  const batch = (await db()).createBatch();
  const changes: Record<string, unknown> = {};
  let changed = false;
  const event = (field: string, oldValue: string | null, newValue: string | null) => {
    batch.collection("issue_events").create({
      issue: issue.id,
      actor: actorId,
      field,
      old_value: oldValue ?? "",
      new_value: newValue ?? "",
      created_at: now,
    });
  };
  const str = (v: number | null) => (v === null ? null : String(v));

  if (patch.title !== undefined && patch.title !== issue.title) {
    changes.title = patch.title;
    event("title", issue.title, patch.title);
  }
  if (patch.body !== undefined && patch.body !== issue.body) changes.body = patch.body;
  if (patch.status !== undefined && patch.status !== issue.status) {
    changes.status = patch.status;
    changes.state = isClosedStatus(patch.status) ? "closed" : "open";
    event("status", issue.status, patch.status);
  }
  if (patch.priority !== undefined && patch.priority !== issue.priority) {
    changes.priority = patch.priority;
    event("priority", String(issue.priority), String(patch.priority));
  }
  if (patch.assigneeId !== undefined && patch.assigneeId !== issue.assigneeId) {
    changes.assignee = patch.assigneeId ?? "";
    const assignee = patch.assigneeId ? await getUserById(patch.assigneeId) : null;
    event("assignee", issue.assignee, assignee?.username ?? null);
  }
  if (patch.estimate !== undefined && patch.estimate !== issue.estimate) {
    changes.estimate = patch.estimate ?? 0;
    event("estimate", str(issue.estimate), str(patch.estimate));
  }
  if (patch.dueDate !== undefined && patch.dueDate !== issue.dueDate) {
    changes.due_date = patch.dueDate ?? "";
    event("due_date", issue.dueDate, patch.dueDate);
  }
  if (patch.labelIds) {
    const before = new Set(issue.labels.map((l) => l.id));
    const after = new Set(patch.labelIds);
    const added = [...after].filter((id) => !before.has(id));
    const removed = [...before].filter((id) => !after.has(id));
    if (added.length || removed.length) {
      const names = await labelNames([...added, ...removed]);
      for (const id of added) event("label", null, names.get(id) ?? null);
      for (const id of removed) event("label", names.get(id) ?? null, null);
      changes.labels = [...after];
    }
  }
  if (Object.keys(changes).length > 0) {
    changes.updated_at = now;
    changed = true;
  }
  if (patch.sortOrder !== undefined && patch.sortOrder !== issue.sortOrder) {
    changes.sort_order = patch.sortOrder;
    changed = true;
  }
  if (!changed) return;
  batch.collection("issues").update(issue.id, changes);
  await batch.send();
}

export async function deleteIssue(issueId: string) {
  await (await issues()).delete(issueId);
}

// ---------------------------------------------------------------------------
// Labels

export class LabelError extends Error {}

export async function listLabels(repoId: string) {
  const rows = await (await db()).collection("labels").getFullList({ filter: filter("repo = {:repoId}", { repoId }) });
  return rows.map(toLabel).sort(byName);
}

export async function labelUsage(repoId: string) {
  const rows = await (await issues()).getFullList({ filter: filter("repo = {:repoId} && labels:length > 0", { repoId }), fields: "labels" });
  const usage = new Map<string, number>();
  for (const r of rows) for (const id of r.labels as string[]) usage.set(id, (usage.get(id) ?? 0) + 1);
  return usage;
}

export async function createLabel(repoId: string, input: { name: string; color: string }): Promise<IssueLabel> {
  const name = input.name.trim().slice(0, 50);
  if (!name) throw new LabelError("Label names can't be blank.");
  if (!/^#[0-9a-f]{6}$/i.test(input.color)) throw new LabelError("Pick a label color.");
  const taken = new LabelError(`A label named "${name}" already exists.`);
  if (await findFirst("labels", filter("repo = {:repoId} && name:lower = {:name}", { repoId, name: name.toLowerCase() }), { fields: "id" })) {
    throw taken;
  }
  try {
    const row = await (await db()).collection("labels").create({ repo: repoId, name, color: input.color.toLowerCase() });
    return toLabel(row);
  } catch (e) {
    if (isUniqueViolation(e)) throw taken;
    throw e;
  }
}

/** Deletes a label; PocketBase takes it off the issues that had it. */
export async function deleteLabel(repoId: string, labelId: string) {
  const row = await findFirst("labels", filter("id = {:labelId} && repo = {:repoId}", { labelId, repoId }), { fields: "id" });
  if (row) await (await db()).collection("labels").delete(row.id);
}