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 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 the owner, collaborators 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, the collaborators, and their agents that may write to the repository. */
export async function listMembers(repoId: string, ownerId: string): Promise<Member[]> {
const [rows, agents] = await Promise.all([
(await db()).collection("users").getFullList({
filter: filter("id = {:ownerId} || collaborators_via_user.repo ?= {:repoId}", { 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);
}