diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 552db3b0d7d..44677f6c499 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -170,6 +170,71 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { 20_000, ); + it( + "replays migration 0128 safely and keeps issue project backfill idempotent", + async () => { + const connectionString = await createTempDatabase(); + + await applyPendingMigrations(connectionString); + + const issueProjectsHash = await migrationHash("0128_issue_projects.sql"); + const companyId = "10000000-0000-4000-8000-000000000001"; + const projectId = "20000000-0000-4000-8000-000000000001"; + const issueId = "30000000-0000-4000-8000-000000000001"; + + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + await sql.unsafe(` + INSERT INTO "companies" ("id", "name", "issue_prefix") + VALUES ('${companyId}', 'Paperclip', 'PAP') + `); + await sql.unsafe(` + INSERT INTO "projects" ("id", "company_id", "name", "status") + VALUES ('${projectId}', '${companyId}', 'Core', 'in_progress') + `); + await sql.unsafe(` + INSERT INTO "issues" ("id", "company_id", "project_id", "title", "status", "priority") + VALUES ('${issueId}', '${companyId}', '${projectId}', 'Backfill me', 'todo', 'medium') + `); + await sql.unsafe(`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${issueProjectsHash}'`); + } finally { + await sql.end(); + } + + await applyPendingMigrations(connectionString); + + const sqlAfterFirstReplay = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const rows = await sqlAfterFirstReplay.unsafe<{ issue_id: string; project_id: string; is_primary: boolean }[]>(` + SELECT "issue_id", "project_id", "is_primary" + FROM "issue_projects" + WHERE "issue_id" = '${issueId}' + `); + expect(rows).toEqual([{ issue_id: issueId, project_id: projectId, is_primary: true }]); + await sqlAfterFirstReplay.unsafe( + `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${issueProjectsHash}'`, + ); + } finally { + await sqlAfterFirstReplay.end(); + } + + await applyPendingMigrations(connectionString); + + const sqlAfterSecondReplay = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const rows = await sqlAfterSecondReplay.unsafe<{ count: string; primary_count: string }[]>(` + SELECT count(*)::text, count(*) FILTER (WHERE "is_primary")::text AS "primary_count" + FROM "issue_projects" + WHERE "issue_id" = '${issueId}' AND "project_id" = '${projectId}' + `); + expect(rows[0]).toEqual({ count: "1", primary_count: "1" }); + } finally { + await sqlAfterSecondReplay.end(); + } + }, + 20_000, + ); + it( "replays migration 0046 safely when document revision columns already exist", async () => { diff --git a/packages/db/src/migrations/0131_issue_projects.sql b/packages/db/src/migrations/0131_issue_projects.sql new file mode 100644 index 00000000000..fad909c0a89 --- /dev/null +++ b/packages/db/src/migrations/0131_issue_projects.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS "issue_projects" ( + "issue_id" uuid NOT NULL, + "project_id" uuid NOT NULL, + "company_id" uuid NOT NULL, + "is_primary" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "issue_projects_pk" PRIMARY KEY("issue_id","project_id"), + CONSTRAINT "issue_projects_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action, + CONSTRAINT "issue_projects_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action, + CONSTRAINT "issue_projects_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "issue_projects_one_primary_uq" + ON "issue_projects" USING btree ("issue_id") + WHERE "is_primary" = true; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_projects_company_project_idx" + ON "issue_projects" USING btree ("company_id","project_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_projects_project_idx" + ON "issue_projects" USING btree ("project_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_projects_issue_idx" + ON "issue_projects" USING btree ("issue_id"); +--> statement-breakpoint +INSERT INTO "issue_projects" ("issue_id", "project_id", "company_id", "is_primary") +SELECT "id", "project_id", "company_id", true +FROM "issues" +WHERE "project_id" IS NOT NULL +ON CONFLICT ("issue_id", "project_id") DO NOTHING; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index e3e0473eb20..7de0df494db 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -918,6 +918,13 @@ "when": 1783025224120, "tag": "0130_run_responsible_user_invariant", "breakpoints": true + }, + { + "idx": 131, + "version": "7", + "when": 1783296000000, + "tag": "0131_issue_projects", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 6d418922cce..e5ba60b7852 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -55,6 +55,7 @@ export { pipelineCaseEvents } from "./pipeline_case_events.js"; export { issueWorkProducts } from "./issue_work_products.js"; export { labels } from "./labels.js"; export { issueLabels } from "./issue_labels.js"; +export { issueProjects } from "./issue_projects.js"; export { issueApprovals } from "./issue_approvals.js"; export { issueComments } from "./issue_comments.js"; export { issueThreadInteractions } from "./issue_thread_interactions.js"; diff --git a/packages/db/src/schema/issue_projects.ts b/packages/db/src/schema/issue_projects.ts new file mode 100644 index 00000000000..77a830792a0 --- /dev/null +++ b/packages/db/src/schema/issue_projects.ts @@ -0,0 +1,23 @@ +import { sql } from "drizzle-orm"; +import { pgTable, uuid, timestamp, boolean, index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; +import { projects } from "./projects.js"; + +export const issueProjects = pgTable( + "issue_projects", + { + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + isPrimary: boolean("is_primary").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.issueId, table.projectId], name: "issue_projects_pk" }), + onePrimaryUq: uniqueIndex("issue_projects_one_primary_uq").on(table.issueId).where(sql`${table.isPrimary}`), + companyProjectIdx: index("issue_projects_company_project_idx").on(table.companyId, table.projectId), + projectIdx: index("issue_projects_project_idx").on(table.projectId), + issueIdx: index("issue_projects_issue_idx").on(table.issueId), + }), +); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 39cd067b09c..f61fad3a1fd 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { asc, eq } from "drizzle-orm"; +import { asc, desc, eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { sql } from "drizzle-orm"; import { @@ -17,6 +17,7 @@ import { issueComments, issueInboxArchives, issueDocuments, + issueProjects, issuePlanDecompositions, issueRelations, issueThreadInteractions, @@ -35,7 +36,10 @@ import { deriveIssueCommentRunLogAttribution, ISSUE_LIST_MAX_LIMIT, issueService, + MAX_ISSUE_PROJECTS, + setIssueProjects, } from "../services/issues.ts"; +import { projectService } from "../services/projects.ts"; import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH, type IssueWorkMode } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -285,6 +289,210 @@ if (!embeddedPostgresSupport.supported) { ); } +describeEmbeddedPostgres("issueService project memberships", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-projects-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueProjects); + await db.delete(issues); + await db.delete(projects); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `P${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + async function seedProject(companyId: string, input: { id?: string; name?: string; archived?: boolean } = {}) { + const [project] = await db + .insert(projects) + .values({ + id: input.id ?? randomUUID(), + companyId, + name: input.name ?? `Project ${randomUUID().slice(0, 8)}`, + status: "in_progress", + archivedAt: input.archived ? new Date() : null, + }) + .returning(); + return project!; + } + + async function seedIssue(companyId: string) { + return await svc.create(companyId, { + title: "Attach to projects", + description: null, + status: "todo", + priority: "medium", + }); + } + + async function listIssueProjects(issueId: string) { + return await db + .select({ + projectId: issueProjects.projectId, + isPrimary: issueProjects.isPrimary, + }) + .from(issueProjects) + .where(eq(issueProjects.issueId, issueId)) + .orderBy(desc(issueProjects.isPrimary), asc(issueProjects.createdAt), asc(issueProjects.projectId)); + } + + async function getIssueProjectId(issueId: string) { + return await db + .select({ projectId: issues.projectId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0]?.projectId ?? null); + } + + it("sets, replaces, and clears memberships while mirroring the primary project", async () => { + const companyId = await seedCompany(); + const [projectOne, projectTwo, projectThree] = await Promise.all([ + seedProject(companyId, { name: "One" }), + seedProject(companyId, { name: "Two" }), + seedProject(companyId, { name: "Three" }), + ]); + const issue = await seedIssue(companyId); + + await db.transaction((tx) => setIssueProjects(tx, issue.id, [projectOne.id, projectTwo.id])); + + expect(await listIssueProjects(issue.id)).toEqual([ + { projectId: projectOne.id, isPrimary: true }, + { projectId: projectTwo.id, isPrimary: false }, + ]); + expect(await getIssueProjectId(issue.id)).toBe(projectOne.id); + + await db.transaction((tx) => setIssueProjects(tx, issue.id, [projectTwo.id, projectThree.id])); + + expect(await listIssueProjects(issue.id)).toEqual([ + { projectId: projectTwo.id, isPrimary: true }, + { projectId: projectThree.id, isPrimary: false }, + ]); + expect(await getIssueProjectId(issue.id)).toBe(projectTwo.id); + + await db.transaction((tx) => setIssueProjects(tx, issue.id, [])); + + expect(await listIssueProjects(issue.id)).toEqual([]); + expect(await getIssueProjectId(issue.id)).toBeNull(); + }); + + it("keeps legacy projectId create and update paths mirrored into issue_projects", async () => { + const companyId = await seedCompany(); + const projectOne = await seedProject(companyId, { name: "One" }); + const projectTwo = await seedProject(companyId, { name: "Two" }); + + const issue = await svc.create(companyId, { + title: "Legacy project id", + description: null, + status: "todo", + priority: "medium", + projectId: projectOne.id, + }); + + expect(await listIssueProjects(issue.id)).toEqual([ + { projectId: projectOne.id, isPrimary: true }, + ]); + + await svc.update(issue.id, { projectId: projectTwo.id }); + + expect(await listIssueProjects(issue.id)).toEqual([ + { projectId: projectTwo.id, isPrimary: true }, + ]); + expect(await getIssueProjectId(issue.id)).toBe(projectTwo.id); + + await svc.update(issue.id, { projectId: null }); + + expect(await listIssueProjects(issue.id)).toEqual([]); + expect(await getIssueProjectId(issue.id)).toBeNull(); + }); + + it("dedupes with first occurrence winning and enforces the membership cap", async () => { + const companyId = await seedCompany(); + const projectIds = await Promise.all( + Array.from({ length: MAX_ISSUE_PROJECTS + 1 }, (_, index) => + seedProject(companyId, { name: `Project ${index}` }).then((project) => project.id), + ), + ); + const issue = await seedIssue(companyId); + + await svc.setProjects(issue.id, [projectIds[1]!, projectIds[0]!, projectIds[1]!]); + + expect(await listIssueProjects(issue.id)).toEqual([ + { projectId: projectIds[1]!, isPrimary: true }, + { projectId: projectIds[0]!, isPrimary: false }, + ]); + expect(await getIssueProjectId(issue.id)).toBe(projectIds[1]); + + await expect(svc.setProjects(issue.id, projectIds)).rejects.toMatchObject({ + status: 422, + }); + }); + + it("rejects cross-company and archived projects", async () => { + const companyId = await seedCompany(); + const otherCompanyId = await seedCompany(); + const issue = await seedIssue(companyId); + const otherProject = await seedProject(otherCompanyId); + const archivedProject = await seedProject(companyId, { archived: true }); + + await expect(svc.setProjects(issue.id, [otherProject.id])).rejects.toMatchObject({ + status: 422, + message: "Issue projects must belong to the same company as the issue", + }); + await expect(svc.setProjects(issue.id, [archivedProject.id])).rejects.toMatchObject({ + status: 422, + message: "Archived projects cannot be attached to issues", + }); + }); + + it("promotes the oldest remaining membership when the primary project is removed", async () => { + const companyId = await seedCompany(); + const projectOne = await seedProject(companyId, { name: "One" }); + const projectTwo = await seedProject(companyId, { name: "Two" }); + const projectThree = await seedProject(companyId, { name: "Three" }); + const issue = await seedIssue(companyId); + + await svc.setProjects(issue.id, [projectOne.id, projectTwo.id, projectThree.id]); + await projectService(db).remove(projectOne.id); + + expect(await listIssueProjects(issue.id)).toEqual([ + { projectId: projectTwo.id, isPrimary: true }, + { projectId: projectThree.id, isPrimary: false }, + ]); + expect(await getIssueProjectId(issue.id)).toBe(projectTwo.id); + + await projectService(db).remove(projectTwo.id); + expect(await listIssueProjects(issue.id)).toEqual([ + { projectId: projectThree.id, isPrimary: true }, + ]); + expect(await getIssueProjectId(issue.id)).toBe(projectThree.id); + + await projectService(db).remove(projectThree.id); + expect(await listIssueProjects(issue.id)).toEqual([]); + expect(await getIssueProjectId(issue.id)).toBeNull(); + }); +}); + describeEmbeddedPostgres("issueService.list participantAgentId", () => { let db!: ReturnType; let svc!: ReturnType; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 330f57117f4..22601cf069e 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -21,6 +21,7 @@ import { issueAttachments, issueInboxArchives, issueLabels, + issueProjects, issueWatchdogs, issuePlanDecompositions, issueRecoveryActions, @@ -462,6 +463,7 @@ type IssueUserContextInput = { }; type ProjectGoalReader = Pick; type DbReader = Pick; +type IssueProjectWriter = Pick; type IssueCreateInput = Omit & { labelIds?: string[]; blockedByIssueIds?: string[]; @@ -478,6 +480,148 @@ type IssueChildCreateInput = IssueCreateInput & { actorAgentId?: string | null; actorUserId?: string | null; }; + +export const MAX_ISSUE_PROJECTS = 8; + +function dedupeIssueProjectIds(projectIds: readonly string[]) { + const seen = new Set(); + const deduped: string[] = []; + for (const projectId of projectIds) { + if (seen.has(projectId)) continue; + seen.add(projectId); + deduped.push(projectId); + } + return deduped; +} + +async function validateIssueProjectIds( + tx: IssueProjectWriter, + companyId: string, + projectIds: readonly string[], +) { + if (projectIds.length === 0) return; + const rows = await tx + .select({ + id: projects.id, + companyId: projects.companyId, + archivedAt: projects.archivedAt, + }) + .from(projects) + .where(inArray(projects.id, [...projectIds])); + const byId = new Map(rows.map((row) => [row.id, row])); + for (const projectId of projectIds) { + const project = byId.get(projectId); + if (!project) { + throw unprocessable("One or more projects are invalid for this issue"); + } + if (project.companyId !== companyId) { + throw unprocessable("Issue projects must belong to the same company as the issue"); + } + if (project.archivedAt) { + throw unprocessable("Archived projects cannot be attached to issues"); + } + } +} + +export async function setIssueProjects( + tx: IssueProjectWriter, + issueId: string, + projectIds: readonly string[], +) { + const dedupedProjectIds = dedupeIssueProjectIds(projectIds); + if (dedupedProjectIds.length > MAX_ISSUE_PROJECTS) { + throw unprocessable(`Issues can belong to at most ${MAX_ISSUE_PROJECTS} projects`); + } + + const issue = await tx + .select({ id: issues.id, companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + if (!issue) throw notFound("Issue not found"); + + await validateIssueProjectIds(tx, issue.companyId, dedupedProjectIds); + + await tx + .update(issueProjects) + .set({ isPrimary: false }) + .where(eq(issueProjects.issueId, issueId)); + + if (dedupedProjectIds.length === 0) { + await tx.delete(issueProjects).where(eq(issueProjects.issueId, issueId)); + await tx.update(issues).set({ projectId: null, updatedAt: new Date() }).where(eq(issues.id, issueId)); + return { issueId, projectIds: [], primaryProjectId: null }; + } + + await tx + .delete(issueProjects) + .where(and(eq(issueProjects.issueId, issueId), notInArray(issueProjects.projectId, dedupedProjectIds))); + + const createdAtBase = Date.now(); + await tx + .insert(issueProjects) + .values( + dedupedProjectIds.map((projectId, index) => ({ + issueId, + projectId, + companyId: issue.companyId, + isPrimary: index === 0, + createdAt: new Date(createdAtBase + index), + })), + ) + .onConflictDoUpdate({ + target: [issueProjects.issueId, issueProjects.projectId], + set: { + companyId: issue.companyId, + isPrimary: sql`excluded.is_primary`, + }, + }); + + const primaryProjectId = dedupedProjectIds[0] ?? null; + await tx.update(issues).set({ projectId: primaryProjectId, updatedAt: new Date() }).where(eq(issues.id, issueId)); + return { issueId, projectIds: dedupedProjectIds, primaryProjectId }; +} + +export async function promoteIssueProjectsAfterProjectRemoval( + tx: IssueProjectWriter, + projectId: string, +) { + const primaryIssueRows = await tx + .select({ issueId: issues.id }) + .from(issues) + .where(eq(issues.projectId, projectId)); + + await tx.delete(issueProjects).where(eq(issueProjects.projectId, projectId)); + + for (const row of primaryIssueRows) { + const replacement = await tx + .select({ projectId: issueProjects.projectId }) + .from(issueProjects) + .where(eq(issueProjects.issueId, row.issueId)) + .orderBy(asc(issueProjects.createdAt), asc(issueProjects.projectId)) + .limit(1) + .then((rows) => rows[0] ?? null); + + await tx + .update(issueProjects) + .set({ isPrimary: false }) + .where(eq(issueProjects.issueId, row.issueId)); + + if (replacement) { + await tx + .update(issueProjects) + .set({ isPrimary: true }) + .where(and(eq(issueProjects.issueId, row.issueId), eq(issueProjects.projectId, replacement.projectId))); + } + + await tx + .update(issues) + .set({ projectId: replacement?.projectId ?? null, updatedAt: new Date() }) + .where(eq(issues.id, row.issueId)); + } + + return primaryIssueRows.length; +} type AcceptedPlanDecompositionInput = { acceptedPlanRevisionId: string; children: IssueChildCreateInput[]; @@ -5550,6 +5694,9 @@ export function issueService(db: Db) { ); const [issue] = await tx.insert(issues).values(values).returning(); + if (issue.projectId) { + await setIssueProjects(tx, issue.id, [issue.projectId]); + } if (watchdog) { await upsertIssueWatchdogForIssue(tx, companyId, issue.id, { agentId: watchdog.agentId, @@ -5748,6 +5895,9 @@ export function issueService(db: Db) { .returning() .then((rows: Array) => rows[0] ?? null); if (!updated) return null; + if (issueData.projectId !== undefined || patch.projectId !== undefined) { + await setIssueProjects(tx, updated.id, updated.projectId ? [updated.projectId] : []); + } if (nextLabelIds !== undefined) { await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx); } @@ -5820,6 +5970,8 @@ export function issueService(db: Db) { return dbOrTx === db ? db.transaction(runUpdate) : runUpdate(dbOrTx); }, + setProjects: async (id: string, projectIds: string[]) => db.transaction((tx) => setIssueProjects(tx, id, projectIds)), + clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => { const rows = await db .select({ diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 7cb38f676d2..e3bc080c179 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -32,6 +32,7 @@ import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runt import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; +import { promoteIssueProjectsAfterProjectRemoval } from "./issues.js"; type ProjectRow = typeof projects.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; @@ -857,15 +858,16 @@ export function projectService(db: Db) { }, remove: (id: string) => - db - .delete(projects) - .where(eq(projects.id, id)) - .returning() - .then((rows) => { + db.transaction(async (tx) => { + await promoteIssueProjectsAfterProjectRemoval(tx, id); + const rows = await tx + .delete(projects) + .where(eq(projects.id, id)) + .returning(); const row = rows[0] ?? null; if (!row) return null; return { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) }; - }), + }), listWorkspaces: async (projectId: string): Promise => { const rows = await db