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(),