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:
26
packages/db/src/migrations/0018_flat_sleepwalker.sql
Normal file
26
packages/db/src/migrations/0018_flat_sleepwalker.sql
Normal 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");
|
||||
5450
packages/db/src/migrations/meta/0018_snapshot.json
Normal file
5450
packages/db/src/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
20
packages/db/src/schema/issue_labels.ts
Normal file
20
packages/db/src/schema/issue_labels.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
18
packages/db/src/schema/labels.ts
Normal file
18
packages/db/src/schema/labels.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user