Vectrum
Sign In
458 lines · 19.0 KB · TypeScript
import { agentFields, isAgentKey, type Actor } from "./actor";
import { listAssignableAgents } from "./agents";
import { db, filter, findFirst, isUniqueViolation, orNull, withLock, type RecordModel } from "./db";
import { emitEvent } from "./events";
import {
  ESTIMATES,
  isClosedStatus,
  isValidDay,
  STATUS_IDS,
  type Issue,
  type IssueLabel,
  type IssuePatch,
  type IssueSummary,
  type Member,
  type NewIssueInput,
} from "./issue-meta";
import { orgPushersFilter } from "./orgs";
import type { RepoRecord } from "./repos";
import { getUserById } from "./users";

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

export type Comment = { id: string; author: string; agent: string | null; body: string; createdAt: number };
export type IssueEvent = {
  id: string;
  actor: string;
  /** The agent that made the change for `actor`. */
  agent: string | null;
  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,agent_name,state,status,priority,assignee,assignee_agent,assignee_agent_name,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 ?? "",
  authorAgent: orNull(r.agent_name),
  status: r.status,
  priority: r.priority,
  // An agent stands in for its person.
  assigneeId: orNull(r.assignee_agent) ?? orNull(r.assignee),
  assignee: r.assignee_agent ? r.assignee_agent_name || "Agent" : (r.expand?.assignee?.username ?? null),
  assigneeAgentOf: r.assignee_agent ? (r.expand?.assignee?.username ?? null) : 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 people with push access and their connected agents 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 ?? "", agent: orNull(r.agent_name), 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 ?? "",
    agent: orNull(r.agent_name),
    field: r.field,
    oldValue: orNull(r.old_value),
    newValue: orNull(r.new_value),
    createdAt: r.created_at,
  }));
}

/**
 * Who can be assigned: the owner (unless it's an organization), the collaborators, the members of
 * the organization who can push, and their agents that may write to the repository.
 */
export async function listMembers(repoId: string, ownerId: string): Promise<Member[]> {
  const orgPushers = await orgPushersFilter("id", ownerId);
  const [rows, agents] = await Promise.all([
    (await db()).collection("users").getFullList({
      filter: filter(`(id = {:ownerId} && kind != 'org') || collaborators_via_user.repo ?= {:repoId}${orgPushers ? ` || ${orgPushers}` : ""}`, { ownerId, repoId }),
      sort: "username",
      fields: "id,username,name",
    }),
    listAssignableAgents({ id: repoId, ownerId }),
  ]);
  return [
    ...rows.map((r) => ({ id: r.id, username: r.username, name: r.name })),
    ...agents.map((a) => ({ id: a.key, username: a.name, name: "", agentOf: a.username })),
  ];
}

/**
 * Hands out the repository's next number, for an issue or a pull request (they share numbers, so
 * "#12" means one thing), while `fn` creates the record. The unique indexes catch anything that
 * slips past.
 */
export function withNextNumber<T>(repoId: string, fn: (number: number) => Promise<T>) {
  return withLock(`issues:${repoId}`, async () => {
    const [issue, pull] = await Promise.all([
      findFirst("issues", filter("repo = {:repoId}", { repoId }), { sort: "-number", fields: "number" }),
      findFirst("pull_requests", filter("repo = {:repoId}", { repoId }), { sort: "-number", fields: "number" }),
    ]);
    return fn(Math.max(issue?.number ?? 0, pull?.number ?? 0) + 1);
  });
}

/** The fields an issue's assignee is stored in: a person, or an agent and the person it acts for. */
async function assigneeFields(repo: { id: string; ownerId: string }, assigneeId: string | null | undefined) {
  if (!assigneeId) return { assignee: "", assignee_agent: "", assignee_agent_name: "" };
  if (!isAgentKey(assigneeId)) return { assignee: assigneeId, assignee_agent: "", assignee_agent_name: "" };
  const agent = (await listAssignableAgents(repo)).find((a) => a.key === assigneeId);
  if (!agent) throw new IssueError("That agent can't be assigned here.");
  return { assignee: agent.userId, assignee_agent: agent.key, assignee_agent_name: agent.name };
}

/** An issue as events and agents see it. */
export function issuePayload(i: IssueSummary | Issue) {
  return {
    number: i.number,
    title: i.title,
    ...("body" in i ? { body: i.body, state: i.state } : {}),
    status: i.status,
    priority: i.priority,
    assignee: i.assignee ? { name: i.assignee, agent: i.assigneeAgentOf !== null, for: i.assigneeAgentOf } : null,
    labels: i.labels.map((l) => l.name),
    author: i.author,
    author_agent: i.authorAgent,
    comments: i.commentCount,
    created_at: new Date(i.createdAt).toISOString(),
    updated_at: new Date(i.updatedAt).toISOString(),
  };
}

export async function createIssue(repo: RepoRecord, actor: Actor, input: NewIssueInput) {
  const number = await withNextNumber(repo.id, async (number) => {
    const now = Date.now();
    const status = input.status ?? "todo";
    await (await issues()).create({
      repo: repo.id,
      number,
      title: input.title,
      body: input.body,
      author: actor.userId,
      ...agentFields(actor.agent),
      state: isClosedStatus(status) ? "closed" : "open",
      status,
      priority: input.priority ?? 0,
      ...(await assigneeFields(repo, input.assigneeId)),
      estimate: input.estimate ?? 0,
      due_date: input.dueDate ?? "",
      sort_order: -now,
      labels: input.labelIds ?? [],
      created_at: now,
      updated_at: now,
    });
    return number;
  });
  const created = await getIssue(repo.id, number);
  if (created) await emitEvent(repo, { kind: "issues", action: "opened", actor, subject: `#${number}`, payload: { issue: issuePayload(created) } });
  return number;
}

export async function addComment(repo: RepoRecord, issue: Issue, actor: Actor, body: string) {
  const now = Date.now();
  const batch = (await db()).createBatch();
  batch.collection("issue_comments").create({ issue: issue.id, author: actor.userId, ...agentFields(actor.agent), body, created_at: now });
  batch.collection("issues").update(issue.id, { updated_at: now, "comment_count+": 1 });
  const [created] = await batch.send();
  const user = await getUserById(actor.userId);
  await emitEvent(repo, {
    kind: "issue_comment",
    action: "created",
    actor,
    subject: `#${issue.number}`,
    payload: {
      issue: issuePayload({ ...issue, commentCount: issue.commentCount + 1 }),
      comment: { id: created.body?.id ?? null, author: user?.username ?? null, agent: actor.agent?.name ?? null, body, created_at: new Date(now).toISOString() },
    },
  });
}

/** Adds a line to an issue's activity feed, e.g. that a pull request links it. */
export async function recordIssueEvent(issueId: string, actor: Actor, field: string, oldValue: string | null, newValue: string | null) {
  await (await db()).collection("issue_events").create({
    issue: issueId,
    actor: actor.userId,
    ...agentFields(actor.agent),
    field,
    old_value: oldValue ?? "",
    new_value: newValue ?? "",
    created_at: Date.now(),
  });
}

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, records what changed in the activity feed and tells the
 * event feed. Reordering alone doesn't count as an update.
 */
export async function updateIssue(repo: RepoRecord, issue: Issue, patch: IssuePatch, actor: Actor) {
  const now = Date.now();
  const batch = (await db()).createBatch();
  const changes: Record<string, unknown> = {};
  let changed = false;
  /** Events for the feed: [action, extra payload]. */
  const announce: [string, Record<string, unknown>][] = [];
  const edits: Record<string, { from: unknown }> = {};
  const event = (field: string, oldValue: string | null, newValue: string | null) => {
    batch.collection("issue_events").create({
      issue: issue.id,
      actor: actor.userId,
      ...agentFields(actor.agent),
      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);
    edits.title = { from: issue.title };
  }
  if (patch.body !== undefined && patch.body !== issue.body) {
    changes.body = patch.body;
    edits.body = { from: issue.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);
    announce.push(["status_changed", { changes: { status: { from: issue.status, to: patch.status } } }]);
    if (changes.state !== issue.state) announce.push([changes.state === "closed" ? "closed" : "reopened", {}]);
  }
  if (patch.priority !== undefined && patch.priority !== issue.priority) {
    changes.priority = patch.priority;
    event("priority", String(issue.priority), String(patch.priority));
    edits.priority = { from: issue.priority };
  }
  if (patch.assigneeId !== undefined && patch.assigneeId !== issue.assigneeId) {
    Object.assign(changes, await assigneeFields(repo, patch.assigneeId));
    const name = isAgentKey(patch.assigneeId ?? "")
      ? (changes.assignee_agent_name as string)
      : patch.assigneeId
        ? ((await getUserById(patch.assigneeId))?.username ?? null)
        : null;
    event("assignee", issue.assignee, name);
    if (issue.assignee) announce.push(["unassigned", { assignee: { name: issue.assignee, agent: issue.assigneeAgentOf !== null } }]);
    if (patch.assigneeId) announce.push(["assigned", { assignee: { name, agent: isAgentKey(patch.assigneeId), key: isAgentKey(patch.assigneeId) ? patch.assigneeId : undefined } }]);
  }
  if (patch.estimate !== undefined && patch.estimate !== issue.estimate) {
    changes.estimate = patch.estimate ?? 0;
    event("estimate", str(issue.estimate), str(patch.estimate));
    edits.estimate = { from: issue.estimate };
  }
  if (patch.dueDate !== undefined && patch.dueDate !== issue.dueDate) {
    changes.due_date = patch.dueDate ?? "";
    event("due_date", issue.dueDate, patch.dueDate);
    edits.due_date = { from: issue.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);
        announce.push(["labeled", { label: names.get(id) ?? null }]);
      }
      for (const id of removed) {
        event("label", names.get(id) ?? null, null);
        announce.push(["unlabeled", { label: names.get(id) ?? 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();

  if (Object.keys(edits).length) announce.unshift(["edited", { changes: edits }]);
  if (announce.length === 0) return;
  const updated = await getIssue(repo.id, issue.number);
  if (!updated) return;
  for (const [action, extra] of announce) {
    await emitEvent(repo, { kind: "issues", action, actor, subject: `#${issue.number}`, payload: { issue: issuePayload(updated), ...extra } });
  }
}

export async function deleteIssue(repo: RepoRecord, issue: Issue, actor: Actor) {
  await (await issues()).delete(issue.id);
  await emitEvent(repo, { kind: "issues", action: "deleted", actor, subject: `#${issue.number}`, payload: { issue: issuePayload(issue) } });
}

/** Open issues assigned to an agent, across repositories. */
export async function listIssuesAssignedToAgent(agentKey: string) {
  const rows = await (await issues()).getFullList({
    filter: filter("assignee_agent = {:agentKey} && state = 'open'", { agentKey }),
    sort: "priority,-updated_at",
    expand: EXPAND,
    fields: `${SUMMARY_FIELDS},repo`,
  });
  return rows.map((r) => ({ ...toSummary(r), repoId: r.repo as string }));
}

// ---------------------------------------------------------------------------
// 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);
}