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 { orgPushersFilter } from "./orgs"; 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 { 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 people with push access 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 { 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 ?? "", agent: orNull(r.agent_name), 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 ?? "", 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 (unless it's an organization), the collaborators, the members of * the organization who can push, and their agents that may write to the repository. */ export async function listMembers(repoId: string, ownerId: string): Promise { const orgPushers = await orgPushersFilter("id", ownerId); const [rows, agents] = await Promise.all([ (await db()).collection("users").getFullList({ filter: filter(`(id = {:ownerId} && kind != 'org') || collaborators_via_user.repo ?= {:repoId}${orgPushers ? ` || ${orgPushers}` : ""}`, { 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(repoId: string, fn: (number: number) => Promise) { 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(); 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 = {}; let changed = false; /** Events for the feed: [action, extra payload]. */ const announce: [string, Record][] = []; const edits: Record = {}; 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(); 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); }