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