import { db, filter, findFirst, isUniqueViolation, orNull, withLock, type RecordModel } from "./db"; import { isClosedStatus, 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"); /** 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 { 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 { 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 { 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 { 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(); 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 = {}; 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(); 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 { 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); }