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

@@ -0,0 +1,26 @@
CREATE TABLE "issue_labels" (
"issue_id" uuid NOT NULL,
"label_id" uuid NOT NULL,
"company_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "issue_labels_pk" PRIMARY KEY("issue_id","label_id")
);
--> statement-breakpoint
CREATE TABLE "labels" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"name" text NOT NULL,
"color" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "issue_labels" ADD CONSTRAINT "issue_labels_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_labels" ADD CONSTRAINT "issue_labels_label_id_labels_id_fk" FOREIGN KEY ("label_id") REFERENCES "public"."labels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_labels" ADD CONSTRAINT "issue_labels_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "labels" ADD CONSTRAINT "labels_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issue_labels_issue_idx" ON "issue_labels" USING btree ("issue_id");--> statement-breakpoint
CREATE INDEX "issue_labels_label_idx" ON "issue_labels" USING btree ("label_id");--> statement-breakpoint
CREATE INDEX "issue_labels_company_idx" ON "issue_labels" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX "labels_company_idx" ON "labels" USING btree ("company_id");--> statement-breakpoint
CREATE UNIQUE INDEX "labels_company_name_idx" ON "labels" USING btree ("company_id","name");

File diff suppressed because it is too large Load Diff

View File

@@ -12,9 +12,12 @@ export { agentRuntimeState } from "./agent_runtime_state.js";
export { agentTaskSessions } from "./agent_task_sessions.js";
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
export { projects } from "./projects.js";
export { projectWorkspaces } from "./project_workspaces.js";
export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js";
export { issues } from "./issues.js";
export { labels } from "./labels.js";
export { issueLabels } from "./issue_labels.js";
export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js";
export { assets } from "./assets.js";

View File

@@ -0,0 +1,20 @@
import { pgTable, uuid, timestamp, index, primaryKey } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { labels } from "./labels.js";
export const issueLabels = pgTable(
"issue_labels",
{
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
labelId: uuid("label_id").notNull().references(() => labels.id, { onDelete: "cascade" }),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.issueId, table.labelId], name: "issue_labels_pk" }),
issueIdx: index("issue_labels_issue_idx").on(table.issueId),
labelIdx: index("issue_labels_label_idx").on(table.labelId),
companyIdx: index("issue_labels_company_idx").on(table.companyId),
}),
);

View File

@@ -0,0 +1,18 @@
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const labels = pgTable(
"labels",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
name: text("name").notNull(),
color: text("color").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdx: index("labels_company_idx").on(table.companyId),
companyNameIdx: uniqueIndex("labels_company_name_idx").on(table.companyId, table.name),
}),
);

View File

@@ -6,6 +6,7 @@ export {
AGENT_STATUSES,
AGENT_ADAPTER_TYPES,
AGENT_ROLES,
AGENT_ICON_NAMES,
ISSUE_STATUSES,
ISSUE_PRIORITIES,
GOAL_LEVELS,
@@ -36,6 +37,7 @@ export {
type AgentStatus,
type AgentAdapterType,
type AgentRole,
type AgentIconName,
type IssueStatus,
type IssuePriority,
type GoalLevel,
@@ -73,9 +75,11 @@ export type {
AssetImage,
Project,
ProjectGoalRef,
ProjectWorkspace,
Issue,
IssueComment,
IssueAttachment,
IssueLabel,
Goal,
Approval,
ApprovalComment,
@@ -126,15 +130,21 @@ export {
type UpdateAgentPermissions,
createProjectSchema,
updateProjectSchema,
createProjectWorkspaceSchema,
updateProjectWorkspaceSchema,
type CreateProject,
type UpdateProject,
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
createIssueSchema,
createIssueLabelSchema,
updateIssueSchema,
checkoutIssueSchema,
addIssueCommentSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
type CreateIssue,
type CreateIssueLabel,
type UpdateIssue,
type CheckoutIssue,
type AddIssueComment,

View File

@@ -10,7 +10,7 @@ export type {
AdapterEnvironmentTestResult,
} from "./agent.js";
export type { AssetImage } from "./asset.js";
export type { Project, ProjectGoalRef } from "./project.js";
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type {
Issue,
IssueComment,
@@ -18,6 +18,7 @@ export type {
IssueAncestorProject,
IssueAncestorGoal,
IssueAttachment,
IssueLabel,
} from "./issue.js";
export type { Goal } from "./goal.js";
export type { Approval, ApprovalComment } from "./approval.js";

View File

@@ -1,4 +1,5 @@
import type { IssuePriority, IssueStatus } from "../constants.js";
import type { ProjectWorkspace } from "./project.js";
export interface IssueAncestorProject {
id: string;
@@ -6,6 +7,8 @@ export interface IssueAncestorProject {
description: string | null;
status: string;
goalId: string | null;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
}
export interface IssueAncestorGoal {
@@ -31,6 +34,15 @@ export interface IssueAncestor {
goal: IssueAncestorGoal | null;
}
export interface IssueLabel {
id: string;
companyId: string;
name: string;
color: string;
createdAt: Date;
updatedAt: Date;
}
export interface Issue {
id: string;
companyId: string;
@@ -58,6 +70,8 @@ export interface Issue {
completedAt: Date | null;
cancelledAt: Date | null;
hiddenAt: Date | null;
labelIds?: string[];
labels?: IssueLabel[];
createdAt: Date;
updatedAt: Date;
}

View File

@@ -28,18 +28,24 @@ export {
export {
createProjectSchema,
updateProjectSchema,
createProjectWorkspaceSchema,
updateProjectWorkspaceSchema,
type CreateProject,
type UpdateProject,
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
} from "./project.js";
export {
createIssueSchema,
createIssueLabelSchema,
updateIssueSchema,
checkoutIssueSchema,
addIssueCommentSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
type CreateIssue,
type CreateIssueLabel,
type UpdateIssue,
type CheckoutIssue,
type AddIssueComment,

View File

@@ -13,10 +13,18 @@ export const createIssueSchema = z.object({
assigneeUserId: z.string().optional().nullable(),
requestDepth: z.number().int().nonnegative().optional().default(0),
billingCode: z.string().optional().nullable(),
labelIds: z.array(z.string().uuid()).optional(),
});
export type CreateIssue = z.infer<typeof createIssueSchema>;
export const createIssueLabelSchema = z.object({
name: z.string().trim().min(1).max(48),
color: z.string().regex(/^#(?:[0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value"),
});
export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
export const updateIssueSchema = createIssueSchema.partial().extend({
comment: z.string().min(1).optional(),
hiddenAt: z.string().datetime().nullable().optional(),

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

View File

@@ -1,4 +1,4 @@
import type { Approval, Issue, IssueAttachment, IssueComment } from "@paperclip/shared";
import type { Approval, Issue, IssueAttachment, IssueComment, IssueLabel } from "@paperclip/shared";
import { api } from "./client";
export const issuesApi = {
@@ -8,6 +8,10 @@ export const issuesApi = {
const qs = params.toString();
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
},
listLabels: (companyId: string) => api.get<IssueLabel[]>(`/companies/${companyId}/labels`),
createLabel: (companyId: string, data: { name: string; color: string }) =>
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
get: (id: string) => api.get<Issue>(`/issues/${id}`),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Issue>(`/companies/${companyId}/issues`, data),

View File

@@ -14,6 +14,7 @@ export const queryKeys = {
},
issues: {
list: (companyId: string) => ["issues", companyId] as const,
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
listByProject: (companyId: string, projectId: string) =>
["issues", companyId, "project", projectId] as const,
detail: (id: string) => ["issues", "detail", id] as const,