Vectrum
Sign In
300 lines · 11.1 KB · TypeScript
import { db, transaction } from "./db";
import {
  isClosedStatus,
  type Issue,
  type IssueLabel,
  type IssuePatch,
  type IssueSummary,
  type Member,
  type NewIssueInput,
  type Status,
} from "./issue-meta";

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

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

type IssueRow = {
  id: number;
  number: number;
  title: string;
  body?: string;
  author_id: number;
  author: string;
  assignee: string | null;
  state: "open" | "closed";
  status: Status;
  priority: number;
  assignee_id: number | null;
  estimate: number | null;
  due_date: string | null;
  sort_order: number;
  created_at: number;
  updated_at: number;
  comment_count: number;
};

const COLUMNS = `i.id, i.number, i.title, i.author_id, i.state, i.status, i.priority, i.assignee_id, i.estimate,
  i.due_date, i.sort_order, i.created_at, i.updated_at, u.username AS author, a.username AS assignee,
  (SELECT COUNT(*) FROM issue_comments c WHERE c.issue_id = i.id) AS comment_count`;

const FROM = `FROM issues i JOIN users u ON u.id = i.author_id LEFT JOIN users a ON a.id = i.assignee_id`;

const toSummary = (r: IssueRow, labels: IssueLabel[]): IssueSummary => ({
  id: r.id,
  number: r.number,
  title: r.title,
  authorId: r.author_id,
  author: r.author,
  status: r.status,
  priority: r.priority,
  assigneeId: r.assignee_id,
  assignee: r.assignee,
  estimate: r.estimate,
  dueDate: r.due_date,
  sortOrder: r.sort_order,
  labels,
  createdAt: r.created_at,
  updatedAt: r.updated_at,
  commentCount: r.comment_count,
});

function labelsByIssue(where: string, ...params: number[]) {
  const rows = db
    .prepare(
      `SELECT il.issue_id AS issueId, l.id, l.name, l.color FROM issue_labels il
       JOIN labels l ON l.id = il.label_id JOIN issues i ON i.id = il.issue_id WHERE ${where} ORDER BY l.name`,
    )
    .all(...params) as (IssueLabel & { issueId: number })[];
  const map = new Map<number, IssueLabel[]>();
  for (const { issueId, ...label } of rows) {
    if (!map.has(issueId)) map.set(issueId, []);
    map.get(issueId)!.push(label);
  }
  return map;
}

/** Every issue in a repository, without descriptions. Filtering and grouping happen in the browser. */
export function listIssues(repoId: number) {
  const rows = db.prepare(`SELECT ${COLUMNS} ${FROM} WHERE i.repo_id = ? ORDER BY i.number DESC`).all(repoId) as IssueRow[];
  const labels = labelsByIssue("i.repo_id = ?", repoId);
  return rows.map((r) => toSummary(r, labels.get(r.id) ?? []));
}

export function openIssueCount(repoId: number) {
  return (db.prepare("SELECT COUNT(*) AS n FROM issues WHERE repo_id = ? AND state = 'open'").get(repoId) as { n: number }).n;
}

export function getIssue(repoId: number, number: number): Issue | null {
  const row = db
    .prepare(`SELECT ${COLUMNS}, i.body ${FROM} WHERE i.repo_id = ? AND i.number = ?`)
    .get(repoId, number) as IssueRow | undefined;
  if (!row) return null;
  const labels = labelsByIssue("i.id = ?", row.id).get(row.id) ?? [];
  return { ...toSummary(row, labels), body: row.body ?? "", state: row.state };
}

export function listComments(issueId: number) {
  return db
    .prepare(
      `SELECT c.id, u.username AS author, c.body, c.created_at AS createdAt FROM issue_comments c
       JOIN users u ON u.id = c.author_id WHERE c.issue_id = ? ORDER BY c.id`,
    )
    .all(issueId) as Comment[];
}

export function listEvents(issueId: number) {
  return db
    .prepare(
      `SELECT e.id, u.username AS actor, e.field, e.old_value AS oldValue, e.new_value AS newValue, e.created_at AS createdAt
       FROM issue_events e JOIN users u ON u.id = e.actor_id WHERE e.issue_id = ? ORDER BY e.id`,
    )
    .all(issueId) as IssueEvent[];
}

// node:sqlite rows have a null prototype, which can't be passed to client components.
const plain = <T extends object>(rows: unknown[]) => rows.map((r) => ({ ...(r as T) }));

/** People who can be assigned: the owner and the collaborators. */
export function listMembers(repoId: number, ownerId: number) {
  return plain<Member>(
    db
      .prepare(
        `SELECT id, username, name FROM users
         WHERE id = ? OR id IN (SELECT user_id FROM collaborators WHERE repo_id = ?) ORDER BY username`,
      )
      .all(ownerId, repoId),
  );
}

export function createIssue(repoId: number, authorId: number, input: NewIssueInput) {
  return transaction(() => {
    const { next } = db
      .prepare("SELECT COALESCE(MAX(number), 0) + 1 AS next FROM issues WHERE repo_id = ?")
      .get(repoId) as { next: number };
    const now = Date.now();
    const status = input.status ?? "todo";
    const { lastInsertRowid } = db
      .prepare(
        `INSERT INTO issues (repo_id, number, title, body, author_id, state, status, priority, assignee_id, estimate,
         due_date, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      )
      .run(
        repoId,
        next,
        input.title,
        input.body,
        authorId,
        isClosedStatus(status) ? "closed" : "open",
        status,
        input.priority ?? 0,
        input.assigneeId ?? null,
        input.estimate ?? null,
        input.dueDate ?? null,
        -now,
        now,
        now,
      );
    const insert = db.prepare("INSERT INTO issue_labels (issue_id, label_id) VALUES (?, ?)");
    for (const labelId of input.labelIds ?? []) insert.run(lastInsertRowid, labelId);
    return next;
  });
}

export function addComment(issueId: number, data: { body: string; authorId: number }) {
  const now = Date.now();
  db.prepare("INSERT INTO issue_comments (issue_id, author_id, body, created_at) VALUES (?, ?, ?, ?)").run(
    issueId,
    data.authorId,
    data.body,
    now,
  );
  db.prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run(now, issueId);
}

const username = (id: number | null) =>
  id === null ? null : ((db.prepare("SELECT username FROM users WHERE id = ?").get(id) as { username: string } | undefined)?.username ?? null);

/**
 * Applies an already-validated patch and records what changed in the activity
 * feed. Reordering alone doesn't count as an update.
 */
export function updateIssue(issue: Issue, patch: IssuePatch, actorId: number) {
  transaction(() => {
    const now = Date.now();
    const event = db.prepare(
      "INSERT INTO issue_events (issue_id, actor_id, field, old_value, new_value, created_at) VALUES (?, ?, ?, ?, ?, ?)",
    );
    const sets: string[] = [];
    const values: (string | number | null)[] = [];
    const set = (column: string, value: string | number | null) => {
      sets.push(`${column} = ?`);
      values.push(value);
    };
    const str = (v: number | null) => (v === null ? null : String(v));

    if (patch.title !== undefined && patch.title !== issue.title) {
      set("title", patch.title);
      event.run(issue.id, actorId, "title", issue.title, patch.title, now);
    }
    if (patch.body !== undefined && patch.body !== issue.body) set("body", patch.body);
    if (patch.status !== undefined && patch.status !== issue.status) {
      set("status", patch.status);
      set("state", isClosedStatus(patch.status) ? "closed" : "open");
      event.run(issue.id, actorId, "status", issue.status, patch.status, now);
    }
    if (patch.priority !== undefined && patch.priority !== issue.priority) {
      set("priority", patch.priority);
      event.run(issue.id, actorId, "priority", String(issue.priority), String(patch.priority), now);
    }
    if (patch.assigneeId !== undefined && patch.assigneeId !== issue.assigneeId) {
      set("assignee_id", patch.assigneeId);
      event.run(issue.id, actorId, "assignee", issue.assignee, username(patch.assigneeId), now);
    }
    if (patch.estimate !== undefined && patch.estimate !== issue.estimate) {
      set("estimate", patch.estimate);
      event.run(issue.id, actorId, "estimate", str(issue.estimate), str(patch.estimate), now);
    }
    if (patch.dueDate !== undefined && patch.dueDate !== issue.dueDate) {
      set("due_date", patch.dueDate);
      event.run(issue.id, actorId, "due_date", issue.dueDate, patch.dueDate, now);
    }
    let labelsChanged = false;
    if (patch.labelIds) {
      const before = new Set(issue.labels.map((l) => l.id));
      const after = new Set(patch.labelIds);
      const names = labelNames([...before, ...after]);
      for (const id of after) {
        if (before.has(id)) continue;
        db.prepare("INSERT OR IGNORE INTO issue_labels (issue_id, label_id) VALUES (?, ?)").run(issue.id, id);
        event.run(issue.id, actorId, "label", null, names.get(id) ?? null, now);
        labelsChanged = true;
      }
      for (const id of before) {
        if (after.has(id)) continue;
        db.prepare("DELETE FROM issue_labels WHERE issue_id = ? AND label_id = ?").run(issue.id, id);
        event.run(issue.id, actorId, "label", names.get(id) ?? null, null, now);
        labelsChanged = true;
      }
    }
    if (sets.length > 0 || labelsChanged) set("updated_at", now);
    if (patch.sortOrder !== undefined && patch.sortOrder !== issue.sortOrder) set("sort_order", patch.sortOrder);
    if (sets.length > 0) db.prepare(`UPDATE issues SET ${sets.join(", ")} WHERE id = ?`).run(...values, issue.id);
  });
}

export function deleteIssue(issueId: number) {
  db.prepare("DELETE FROM issues WHERE id = ?").run(issueId);
}

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

export class LabelError extends Error {}

export function listLabels(repoId: number) {
  return plain<IssueLabel>(db.prepare("SELECT id, name, color FROM labels WHERE repo_id = ? ORDER BY name").all(repoId));
}

function labelNames(ids: number[]) {
  if (ids.length === 0) return new Map<number, string>();
  const rows = db
    .prepare(`SELECT id, name FROM labels WHERE id IN (${ids.map(() => "?").join(",")})`)
    .all(...ids) as { id: number; name: string }[];
  return new Map(rows.map((r) => [r.id, r.name]));
}

export function labelUsage(repoId: number) {
  const rows = db
    .prepare(
      `SELECT l.id, COUNT(il.issue_id) AS n FROM labels l LEFT JOIN issue_labels il ON il.label_id = l.id
       WHERE l.repo_id = ? GROUP BY l.id`,
    )
    .all(repoId) as { id: number; n: number }[];
  return new Map(rows.map((r) => [r.id, r.n]));
}

export function createLabel(repoId: number, input: { name: string; color: string }): 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.");
  if (db.prepare("SELECT 1 FROM labels WHERE repo_id = ? AND name = ?").get(repoId, name)) {
    throw new LabelError(`A label named "${name}" already exists.`);
  }
  const { lastInsertRowid } = db
    .prepare("INSERT INTO labels (repo_id, name, color) VALUES (?, ?, ?)")
    .run(repoId, name, input.color.toLowerCase());
  return { id: Number(lastInsertRowid), name, color: input.color.toLowerCase() };
}

export function deleteLabel(repoId: number, labelId: number) {
  db.prepare("DELETE FROM labels WHERE id = ? AND repo_id = ?").run(labelId, repoId);
}