feat: add issue labels (DB schema, API, and service)

New labels and issue_labels tables with cascade deletes, unique
per-company name constraint. CRUD routes for labels, label filtering
on issue list, and label sync on issue create/update. All issue
responses now include labels array.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-25 08:38:37 -06:00
parent 9458767942
commit 6f7172c028
14 changed files with 5903 additions and 43 deletions

View File

@@ -4,6 +4,7 @@ import type { Db } from "@paperclip/db";
import {
addIssueCommentSchema,
createIssueAttachmentMetadataSchema,
createIssueLabelSchema,
checkoutIssueSchema,
createIssueSchema,
linkIssueApprovalSchema,
@@ -22,7 +23,7 @@ import {
projectService,
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
import { forbidden, unauthorized } from "../errors.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
@@ -189,10 +190,65 @@ export function issueRoutes(db: Db, storage: StorageService) {
status: req.query.status as string | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
projectId: req.query.projectId as string | undefined,
labelId: req.query.labelId as string | undefined,
});
res.json(result);
});
router.get("/companies/:companyId/labels", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.listLabels(companyId);
res.json(result);
});
router.post("/companies/:companyId/labels", validate(createIssueLabelSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const label = await svc.createLabel(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "label.created",
entityType: "label",
entityId: label.id,
details: { name: label.name, color: label.color },
});
res.status(201).json(label);
});
router.delete("/labels/:labelId", async (req, res) => {
const labelId = req.params.labelId as string;
const existing = await svc.getLabelById(labelId);
if (!existing) {
res.status(404).json({ error: "Label not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const removed = await svc.deleteLabel(labelId);
if (!removed) {
res.status(404).json({ error: "Label not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: removed.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "label.deleted",
entityType: "label",
entityId: removed.id,
details: { name: removed.name, color: removed.color },
});
res.json(removed);
});
router.get("/issues/:id", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@@ -343,7 +399,33 @@ export function issueRoutes(db: Db, storage: StorageService) {
if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
}
const issue = await svc.update(id, updateFields);
let issue;
try {
issue = await svc.update(id, updateFields);
} catch (err) {
if (err instanceof HttpError && err.status === 422) {
logger.warn(
{
issueId: id,
companyId: existing.companyId,
assigneePatch: {
assigneeAgentId:
req.body.assigneeAgentId === undefined ? "__omitted__" : req.body.assigneeAgentId,
assigneeUserId:
req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId,
},
currentAssignee: {
assigneeAgentId: existing.assigneeAgentId,
assigneeUserId: existing.assigneeUserId,
},
error: err.message,
details: err.details,
},
"issue update rejected with 422",
);
}
throw err;
}
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;

View File

@@ -8,8 +8,11 @@ import {
goals,
heartbeatRuns,
issueAttachments,
issueLabels,
issueComments,
issues,
labels,
projectWorkspaces,
projects,
} from "@paperclip/db";
import { conflict, notFound, unprocessable } from "../errors.js";
@@ -45,8 +48,13 @@ export interface IssueFilters {
status?: string;
assigneeAgentId?: string;
projectId?: string;
labelId?: string;
}
type IssueRow = typeof issues.$inferSelect;
type IssueLabelRow = typeof labels.$inferSelect;
type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] };
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null;
@@ -54,6 +62,40 @@ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]);
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
const map = new Map<string, IssueLabelRow[]>();
if (issueIds.length === 0) return map;
const rows = await dbOrTx
.select({
issueId: issueLabels.issueId,
label: labels,
})
.from(issueLabels)
.innerJoin(labels, eq(issueLabels.labelId, labels.id))
.where(inArray(issueLabels.issueId, issueIds))
.orderBy(asc(labels.name), asc(labels.id));
for (const row of rows) {
const existing = map.get(row.issueId);
if (existing) existing.push(row.label);
else map.set(row.issueId, [row.label]);
}
return map;
}
async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWithLabels[]> {
if (rows.length === 0) return [];
const labelsByIssueId = await labelMapForIssues(dbOrTx, rows.map((row) => row.id));
return rows.map((row) => {
const issueLabels = labelsByIssueId.get(row.id) ?? [];
return {
...row,
labels: issueLabels,
labelIds: issueLabels.map((label) => label.id),
};
});
}
export function issueService(db: Db) {
async function assertAssignableAgent(companyId: string, agentId: string) {
const assignee = await db
@@ -96,6 +138,36 @@ export function issueService(db: Db) {
}
}
async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: any = db) {
if (labelIds.length === 0) return;
const existing = await dbOrTx
.select({ id: labels.id })
.from(labels)
.where(and(eq(labels.companyId, companyId), inArray(labels.id, labelIds)));
if (existing.length !== new Set(labelIds).size) {
throw unprocessable("One or more labels are invalid for this company");
}
}
async function syncIssueLabels(
issueId: string,
companyId: string,
labelIds: string[],
dbOrTx: any = db,
) {
const deduped = [...new Set(labelIds)];
await assertValidLabelIds(companyId, deduped, dbOrTx);
await dbOrTx.delete(issueLabels).where(eq(issueLabels.issueId, issueId));
if (deduped.length === 0) return;
await dbOrTx.insert(issueLabels).values(
deduped.map((labelId) => ({
issueId,
labelId,
companyId,
})),
);
}
async function isTerminalOrMissingHeartbeatRun(runId: string) {
const run = await db
.select({ status: heartbeatRuns.status })
@@ -155,27 +227,52 @@ export function issueService(db: Db) {
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.labelId) {
const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId })
.from(issueLabels)
.where(and(eq(issueLabels.companyId, companyId), eq(issueLabels.labelId, filters.labelId)));
if (labeledIssueIds.length === 0) return [];
conditions.push(inArray(issues.id, labeledIssueIds.map((row) => row.issueId)));
}
conditions.push(isNull(issues.hiddenAt));
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
return db.select().from(issues).where(and(...conditions)).orderBy(asc(priorityOrder), desc(issues.updatedAt));
const rows = await db
.select()
.from(issues)
.where(and(...conditions))
.orderBy(asc(priorityOrder), desc(issues.updatedAt));
return withIssueLabels(db, rows);
},
getById: (id: string) =>
db
getById: async (id: string) => {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null),
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
},
getByIdentifier: (identifier: string) =>
db
getByIdentifier: async (identifier: string) => {
const row = await db
.select()
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null),
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
},
create: async (companyId: string, data: Omit<typeof issues.$inferInsert, "companyId">) => {
create: async (
companyId: string,
data: Omit<typeof issues.$inferInsert, "companyId"> & { labelIds?: string[] },
) => {
const { labelIds: inputLabelIds, ...issueData } = data;
if (data.assigneeAgentId && data.assigneeUserId) {
throw unprocessable("Issue can only have one assignee");
}
@@ -198,7 +295,7 @@ export function issueService(db: Db) {
const issueNumber = company.issueCounter;
const identifier = `${company.issuePrefix}-${issueNumber}`;
const values = { ...data, companyId, issueNumber, identifier } as typeof issues.$inferInsert;
const values = { ...issueData, companyId, issueNumber, identifier } as typeof issues.$inferInsert;
if (values.status === "in_progress" && !values.startedAt) {
values.startedAt = new Date();
}
@@ -210,11 +307,15 @@ export function issueService(db: Db) {
}
const [issue] = await tx.insert(issues).values(values).returning();
return issue;
if (inputLabelIds) {
await syncIssueLabels(issue.id, companyId, inputLabelIds, tx);
}
const [enriched] = await withIssueLabels(tx, [issue]);
return enriched;
});
},
update: async (id: string, data: Partial<typeof issues.$inferInsert>) => {
update: async (id: string, data: Partial<typeof issues.$inferInsert> & { labelIds?: string[] }) => {
const existing = await db
.select()
.from(issues)
@@ -222,19 +323,21 @@ export function issueService(db: Db) {
.then((rows) => rows[0] ?? null);
if (!existing) return null;
if (data.status) {
assertTransition(existing.status, data.status);
const { labelIds: nextLabelIds, ...issueData } = data;
if (issueData.status) {
assertTransition(existing.status, issueData.status);
}
const patch: Partial<typeof issues.$inferInsert> = {
...data,
...issueData,
updatedAt: new Date(),
};
const nextAssigneeAgentId =
data.assigneeAgentId !== undefined ? data.assigneeAgentId : existing.assigneeAgentId;
issueData.assigneeAgentId !== undefined ? issueData.assigneeAgentId : existing.assigneeAgentId;
const nextAssigneeUserId =
data.assigneeUserId !== undefined ? data.assigneeUserId : existing.assigneeUserId;
issueData.assigneeUserId !== undefined ? issueData.assigneeUserId : existing.assigneeUserId;
if (nextAssigneeAgentId && nextAssigneeUserId) {
throw unprocessable("Issue can only have one assignee");
@@ -242,36 +345,44 @@ export function issueService(db: Db) {
if (patch.status === "in_progress" && !nextAssigneeAgentId && !nextAssigneeUserId) {
throw unprocessable("in_progress issues require an assignee");
}
if (data.assigneeAgentId) {
await assertAssignableAgent(existing.companyId, data.assigneeAgentId);
if (issueData.assigneeAgentId) {
await assertAssignableAgent(existing.companyId, issueData.assigneeAgentId);
}
if (data.assigneeUserId) {
await assertAssignableUser(existing.companyId, data.assigneeUserId);
if (issueData.assigneeUserId) {
await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
}
applyStatusSideEffects(data.status, patch);
if (data.status && data.status !== "done") {
applyStatusSideEffects(issueData.status, patch);
if (issueData.status && issueData.status !== "done") {
patch.completedAt = null;
}
if (data.status && data.status !== "cancelled") {
if (issueData.status && issueData.status !== "cancelled") {
patch.cancelledAt = null;
}
if (data.status && data.status !== "in_progress") {
if (issueData.status && issueData.status !== "in_progress") {
patch.checkoutRunId = null;
}
if (
(data.assigneeAgentId !== undefined && data.assigneeAgentId !== existing.assigneeAgentId) ||
(data.assigneeUserId !== undefined && data.assigneeUserId !== existing.assigneeUserId)
(issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== existing.assigneeAgentId) ||
(issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== existing.assigneeUserId)
) {
patch.checkoutRunId = null;
}
return db
.update(issues)
.set(patch)
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return db.transaction(async (tx) => {
const updated = await tx
.update(issues)
.set(patch)
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
if (nextLabelIds !== undefined) {
await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx);
}
const [enriched] = await withIssueLabels(tx, [updated]);
return enriched;
});
},
remove: (id: string) =>
@@ -293,7 +404,9 @@ export function issueService(db: Db) {
.where(inArray(assets.id, attachmentAssetIds.map((row) => row.assetId)));
}
return removedIssue;
if (!removedIssue) return null;
const [enriched] = await withIssueLabels(tx, [removedIssue]);
return enriched;
}),
checkout: async (id: string, agentId: string, expectedStatuses: string[], checkoutRunId: string | null) => {
@@ -337,7 +450,10 @@ export function issueService(db: Db) {
.returning()
.then((rows) => rows[0] ?? null);
if (updated) return updated;
if (updated) {
const [enriched] = await withIssueLabels(db, [updated]);
return enriched;
}
const current = await db
.select({
@@ -394,7 +510,11 @@ export function issueService(db: Db) {
actorRunId: checkoutRunId,
expectedCheckoutRunId: current.checkoutRunId,
});
if (adopted) return db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!);
if (adopted) {
const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!);
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
}
// If this run already owns it and it's in_progress, return it (no self-409)
@@ -403,7 +523,9 @@ export function issueService(db: Db) {
current.status === "in_progress" &&
sameRunLock(current.checkoutRunId, checkoutRunId)
) {
return db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!);
const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!);
const [enriched] = await withIssueLabels(db, [row]);
return enriched;
}
throw conflict("Issue checkout conflict", {
@@ -495,7 +617,7 @@ export function issueService(db: Db) {
});
}
return db
const updated = await db
.update(issues)
.set({
status: "todo",
@@ -506,8 +628,40 @@ export function issueService(db: Db) {
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
const [enriched] = await withIssueLabels(db, [updated]);
return enriched;
},
listLabels: (companyId: string) =>
db.select().from(labels).where(eq(labels.companyId, companyId)).orderBy(asc(labels.name), asc(labels.id)),
getLabelById: (id: string) =>
db
.select()
.from(labels)
.where(eq(labels.id, id))
.then((rows) => rows[0] ?? null),
createLabel: async (companyId: string, data: Pick<typeof labels.$inferInsert, "name" | "color">) => {
const [created] = await db
.insert(labels)
.values({
companyId,
name: data.name.trim(),
color: data.color,
})
.returning();
return created;
},
deleteLabel: async (id: string) =>
db
.delete(labels)
.where(eq(labels.id, id))
.returning()
.then((rows) => rows[0] ?? null),
listComments: (issueId: string) =>
db
.select()
@@ -735,16 +889,79 @@ export function issueService(db: Db) {
const projectIds = [...new Set(raw.map(a => a.projectId).filter((id): id is string => id != null))];
const goalIds = [...new Set(raw.map(a => a.goalId).filter((id): id is string => id != null))];
const projectMap = new Map<string, { id: string; name: string; description: string | null; status: string; goalId: string | null }>();
const projectMap = new Map<string, {
id: string;
name: string;
description: string | null;
status: string;
goalId: string | null;
workspaces: Array<{
id: string;
companyId: string;
projectId: string;
name: string;
cwd: string;
repoUrl: string | null;
repoRef: string | null;
metadata: Record<string, unknown> | null;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
}>;
primaryWorkspace: {
id: string;
companyId: string;
projectId: string;
name: string;
cwd: string;
repoUrl: string | null;
repoRef: string | null;
metadata: Record<string, unknown> | null;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
} | null;
}>();
const goalMap = new Map<string, { id: string; title: string; description: string | null; level: string; status: string }>();
if (projectIds.length > 0) {
const workspaceRows = await db
.select()
.from(projectWorkspaces)
.where(inArray(projectWorkspaces.projectId, projectIds))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const workspaceMap = new Map<string, Array<(typeof workspaceRows)[number]>>();
for (const workspace of workspaceRows) {
const existing = workspaceMap.get(workspace.projectId);
if (existing) existing.push(workspace);
else workspaceMap.set(workspace.projectId, [workspace]);
}
const rows = await db.select({
id: projects.id, name: projects.name, description: projects.description,
status: projects.status, goalId: projects.goalId,
}).from(projects).where(inArray(projects.id, projectIds));
for (const r of rows) {
projectMap.set(r.id, r);
const projectWorkspaceRows = workspaceMap.get(r.id) ?? [];
const workspaces = projectWorkspaceRows.map((workspace) => ({
id: workspace.id,
companyId: workspace.companyId,
projectId: workspace.projectId,
name: workspace.name,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl ?? null,
repoRef: workspace.repoRef ?? null,
metadata: (workspace.metadata as Record<string, unknown> | null) ?? null,
isPrimary: workspace.isPrimary,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
}));
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
projectMap.set(r.id, {
...r,
workspaces,
primaryWorkspace,
});
// Also collect goalIds from projects
if (r.goalId && !goalIds.includes(r.goalId)) goalIds.push(r.goalId);
}