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(); 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 = (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( 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(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(); 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); }