diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 376ef01383a..56aebe1f191 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -652,6 +652,7 @@ export type { IssueExecutionMonitorState, IssueRelation, IssueRelationIssueSummary, + IssueProjectSummary, IssueExecutionPolicy, IssueExecutionState, IssueExecutionStage, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index fd2bb5b1c19..03f5acc8d1d 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -113,6 +113,7 @@ export interface CompanyPortabilityIssueManifestEntry { title: string; path: string; projectSlug: string | null; + projectIds?: string[]; projectWorkspaceKey: string | null; assigneeAgentSlug: string | null; description: string | null; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index b31757fa759..1a87da61c2d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -377,6 +377,7 @@ export type { IssueAncestorGoal, IssueAttachment, IssueLabel, + IssueProjectSummary, IssueWatchdog, IssueWatchdogStatus, IssueWatchdogSummary, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 6dc6d44b670..50b185d622f 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -80,6 +80,14 @@ export interface IssueLabel { updatedAt: Date; } +export interface IssueProjectSummary { + id: string; + name: string; + color: string | null; + icon: string | null; + isPrimary: boolean; +} + export interface IssueAssigneeAdapterOverrides { modelProfile?: ModelProfileKey; adapterConfig?: Record; @@ -139,6 +147,7 @@ export type AcceptedPlanDecompositionStatus = "in_flight" | "completed"; export interface AcceptedPlanDecompositionChild { projectId?: string | null; + projectIds?: string[]; projectWorkspaceId?: string | null; goalId?: string | null; blockedByIssueIds?: string[]; @@ -592,6 +601,7 @@ export interface Issue { documentSummaries?: IssueDocumentSummary[]; legacyPlanDocument?: LegacyPlanDocument | null; project?: Project | null; + projects?: IssueProjectSummary[]; goal?: Goal | null; currentExecutionWorkspace?: ExecutionWorkspace | null; workProducts?: IssueWorkProduct[]; diff --git a/packages/shared/src/types/search.ts b/packages/shared/src/types/search.ts index 3b453cfe51b..2f86300455f 100644 --- a/packages/shared/src/types/search.ts +++ b/packages/shared/src/types/search.ts @@ -1,4 +1,5 @@ import type { IssuePriority, IssueStatus } from "../constants.js"; +import type { IssueProjectSummary } from "./issue.js"; export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "artifacts", "agents", "projects"] as const; export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number]; @@ -26,6 +27,8 @@ export interface CompanySearchIssueSummary { assigneeAgentId: string | null; assigneeUserId: string | null; projectId: string | null; + projectIds?: string[]; + projects?: IssueProjectSummary[]; updatedAt: string; } diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index d6d81d964e2..fbcfd22ad27 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -152,6 +152,7 @@ export const portabilityIssueManifestEntrySchema = z.object({ title: z.string().min(1), path: z.string().min(1), projectSlug: z.string().min(1).nullable(), + projectIds: z.array(z.string().min(1)).optional(), projectWorkspaceKey: z.string().min(1).nullable(), assigneeAgentSlug: z.string().min(1).nullable(), description: z.string().nullable(), diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index b22092ef0d5..9ca35d891e3 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -375,6 +375,7 @@ function withCreateIssueStatusDefault(schema: z.ZodObje const createIssueBaseSchema = z.object({ projectId: z.string().uuid().optional().nullable(), + projectIds: z.array(z.string().uuid()).max(8).optional(), projectWorkspaceId: z.string().uuid().optional().nullable(), goalId: z.string().uuid().optional().nullable(), parentId: z.string().uuid().optional().nullable(), diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index 940f4c0c921..ad8bc0b42f1 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -8,6 +8,7 @@ import { documents, issueComments, issueDocuments, + issueProjects, issues, projects, } from "@paperclipai/db"; @@ -151,6 +152,31 @@ describeEmbeddedPostgres("companySearchService", () => { expect(result.results[0]?.matchedFields).toContain("identifier"); }); + it("returns issue projectIds and ordered project summaries for multi-project memberships", async () => { + const companyId = await createCompany(); + const primaryProjectId = await createProject(companyId, { name: "Primary search project", color: "#111111" }); + const secondaryProjectId = await createProject(companyId, { name: "Secondary search project", color: "#222222" }); + const issueId = await createIssue(companyId, { + identifier: "TST-77", + title: "Multi-project searchable issue", + projectId: primaryProjectId, + }); + await db.insert(issueProjects).values([ + { companyId, issueId, projectId: primaryProjectId, isPrimary: true, createdAt: new Date("2026-01-01T00:00:00.000Z") }, + { companyId, issueId, projectId: secondaryProjectId, isPrimary: false, createdAt: new Date("2026-01-01T00:00:01.000Z") }, + ]); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Multi-project" })); + const match = result.results.find((item) => item.id === issueId); + + expect(match?.issue?.projectId).toBe(primaryProjectId); + expect(match?.issue?.projectIds).toEqual([primaryProjectId, secondaryProjectId]); + expect(match?.issue?.projects).toEqual([ + expect.objectContaining({ id: primaryProjectId, name: "Primary search project", isPrimary: true }), + expect.objectContaining({ id: secondaryProjectId, name: "Secondary search project", isPrimary: false }), + ]); + }); + it("matches multiple tokens across the same issue thread and returns comment snippets", async () => { const companyId = await createCompany(); const issueId = await createIssue(companyId, { diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 8c680f52fb4..e387c46a74f 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -417,6 +417,7 @@ describeEmbeddedPostgres("issueService project memberships", () => { expect(await listIssueProjects(issue.id)).toEqual([ { projectId: projectTwo.id, isPrimary: true }, + { projectId: projectOne.id, isPrimary: false }, ]); expect(await getIssueProjectId(issue.id)).toBe(projectTwo.id); @@ -465,6 +466,173 @@ describeEmbeddedPostgres("issueService project memberships", () => { }); }); + it("accepts projectIds on create and filters list/count by any membership", 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 svc.create(companyId, { + title: "Multi-project issue", + description: null, + status: "todo", + priority: "medium", + projectId: projectThree.id, + projectIds: [projectOne.id, projectTwo.id, projectOne.id], + }); + await svc.create(companyId, { + title: "Primary-only issue", + description: null, + status: "todo", + priority: "medium", + projectId: projectThree.id, + }); + + expect(issue.projectId).toBe(projectOne.id); + expect(issue.projects).toEqual([ + expect.objectContaining({ id: projectOne.id, name: "One", isPrimary: true }), + expect.objectContaining({ id: projectTwo.id, name: "Two", isPrimary: false }), + ]); + expect(await listIssueProjects(issue.id)).toEqual([ + { projectId: projectOne.id, isPrimary: true }, + { projectId: projectTwo.id, isPrimary: false }, + ]); + + const secondaryProjectIssues = await svc.list(companyId, { projectId: projectTwo.id }); + expect(secondaryProjectIssues.map((row) => row.id)).toEqual([issue.id]); + expect(secondaryProjectIssues[0]?.projects).toEqual([ + expect.objectContaining({ id: projectOne.id, isPrimary: true }), + expect.objectContaining({ id: projectTwo.id, isPrimary: false }), + ]); + await expect(svc.count(companyId, { projectId: projectTwo.id })).resolves.toBe(1); + + const detail = await svc.getById(issue.id); + expect(detail?.projects).toEqual([ + expect.objectContaining({ id: projectOne.id, isPrimary: true }), + expect.objectContaining({ id: projectTwo.id, isPrimary: false }), + ]); + }); + + it("lets projectIds win over projectId on update and clears memberships with an empty array", 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 svc.create(companyId, { + title: "Update project ids", + description: null, + status: "todo", + priority: "medium", + projectIds: [projectOne.id, projectTwo.id], + }); + + const projectIdsWin = await svc.update(issue.id, { + projectId: projectThree.id, + projectIds: [projectTwo.id], + }); + expect(projectIdsWin?.projectId).toBe(projectTwo.id); + expect(await listIssueProjects(issue.id)).toEqual([ + { projectId: projectTwo.id, isPrimary: true }, + ]); + + const cleared = await svc.update(issue.id, { projectIds: [] }); + expect(cleared?.projectId).toBeNull(); + expect(cleared?.projects).toEqual([]); + expect(await listIssueProjects(issue.id)).toEqual([]); + }); + + it("keeps secondary memberships when legacy projectId changes the primary on patch", 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 svc.create(companyId, { + title: "Legacy primary patch", + description: null, + status: "todo", + priority: "medium", + projectIds: [projectOne.id, projectTwo.id], + }); + + const updated = await svc.update(issue.id, { projectId: projectThree.id }); + + expect(updated?.projectId).toBe(projectThree.id); + expect(await listIssueProjects(issue.id)).toEqual([ + { projectId: projectThree.id, isPrimary: true }, + { projectId: projectOne.id, isPrimary: false }, + { projectId: projectTwo.id, isPrimary: false }, + ]); + }); + + it("inherits full parent project membership for children unless projectIds is explicit", 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 parent = await svc.create(companyId, { + title: "Parent", + description: null, + status: "todo", + priority: "medium", + projectIds: [projectOne.id, projectTwo.id], + }); + + const inherited = await svc.createChild(parent.id, { + title: "Inherited child", + description: null, + status: "todo", + priority: "medium", + }); + expect(await listIssueProjects(inherited.issue.id)).toEqual([ + { projectId: projectOne.id, isPrimary: true }, + { projectId: projectTwo.id, isPrimary: false }, + ]); + + const explicit = await svc.createChild(parent.id, { + title: "Explicit child", + description: null, + status: "todo", + priority: "medium", + projectIds: [projectThree.id], + }); + expect(await listIssueProjects(explicit.issue.id)).toEqual([ + { projectId: projectThree.id, isPrimary: true }, + ]); + }); + + it("falls back to a legacy scalar parent project when child inheritance has no membership rows", async () => { + const companyId = await seedCompany(); + const project = await seedProject(companyId, { name: "Legacy" }); + const parent = await svc.create(companyId, { + title: "Scalar parent", + description: null, + status: "todo", + priority: "medium", + projectId: project.id, + }); + await db.delete(issueProjects).where(eq(issueProjects.issueId, parent.id)); + + const inherited = await svc.createChild(parent.id, { + title: "Legacy inherited child", + description: null, + status: "todo", + priority: "medium", + }); + + expect(inherited.issue.projectId).toBe(project.id); + expect(await listIssueProjects(inherited.issue.id)).toEqual([ + { projectId: project.id, isPrimary: true }, + ]); + }); + it("promotes the oldest remaining membership when the primary project is removed", async () => { const companyId = await seedCompany(); const projectOne = await seedProject(companyId, { name: "One" }); diff --git a/server/src/__tests__/project-list-metrics.test.ts b/server/src/__tests__/project-list-metrics.test.ts index 267cac63079..314db2bdf1a 100644 --- a/server/src/__tests__/project-list-metrics.test.ts +++ b/server/src/__tests__/project-list-metrics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildProjectListMetricMaps } from "../services/projects.ts"; +import { buildProjectListMetricMaps, buildTaskCountRows } from "../services/projects.ts"; describe("buildProjectListMetricMaps", () => { it("maps task counts by project, coercing string counts to numbers", () => { @@ -24,6 +24,22 @@ describe("buildProjectListMetricMaps", () => { expect(taskCountByProjectId.size).toBe(0); }); + it("builds task counts from issue project memberships without double-counting legacy primary rows", () => { + const rows = buildTaskCountRows([ + { projectId: "p1", issueId: "i1" }, + { projectId: "p2", issueId: "i1" }, + { projectId: "p1", issueId: "i1" }, + { projectId: "p2", issueId: "i2" }, + { projectId: null, issueId: "i3" }, + ]); + + const { taskCountByProjectId } = buildProjectListMetricMaps(rows, []); + + expect(taskCountByProjectId.get("p1")).toBe(1); + expect(taskCountByProjectId.get("p2")).toBe(2); + expect(taskCountByProjectId.size).toBe(2); + }); + it("maps positive budgets with their window kind", () => { const { budgetByProjectId } = buildProjectListMetricMaps( [], diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 9dc4324a6bc..ed7ea63976f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1953,6 +1953,15 @@ export function issueRoutes( return parent.projectId ?? null; } + function primaryProjectIdFromIssueBody( + body: { projectId?: string | null; projectIds?: string[] }, + fallbackProjectId: string | null = null, + ) { + if (Array.isArray(body.projectIds)) return body.projectIds[0] ?? null; + if (body.projectId !== undefined) return body.projectId; + return fallbackProjectId; + } + async function assertCanAssignTasks( req: Request, companyId: string, @@ -5227,6 +5236,11 @@ export function issueRoutes( stopFingerprint: watchdogProductBugFollowUp.scope.stopFingerprint, runId: actor.runId, }), + ...(rawCreateBody.projectIds === undefined && rawCreateBody.projectId === undefined + && watchdogProductBugFollowUp.sourceIssue.projects + && watchdogProductBugFollowUp.sourceIssue.projects.length > 0 + ? { projectIds: watchdogProductBugFollowUp.sourceIssue.projects.map((project) => project.id) } + : {}), projectId: rawCreateBody.projectId ?? watchdogProductBugFollowUp.sourceIssue.projectId, goalId: rawCreateBody.goalId ?? watchdogProductBugFollowUp.sourceIssue.goalId, billingCode: rawCreateBody.billingCode ?? watchdogProductBugFollowUp.sourceIssue.billingCode, @@ -5241,11 +5255,12 @@ export function issueRoutes( } : {}), }; + const createPrimaryProjectId = primaryProjectIdFromIssueBody(createBody); if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, createBody))) return; const createAssignmentScope = { projectId: await resolveAssignmentProjectId({ companyId, - projectId: createBody.projectId, + projectId: createPrimaryProjectId, parentIssueId: createBody.parentId, }), parentIssueId: createBody.parentId ?? null, @@ -5267,7 +5282,7 @@ export function issueRoutes( const sourceTrust = await sourceTrustForActorWrite({ id: issueId, companyId, - projectId: createBody.projectId ?? null, + projectId: createPrimaryProjectId, executionPolicy, }, actor); const issue = await svc.create(companyId, { @@ -5403,8 +5418,9 @@ export function issueRoutes( ...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}), }; if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, createBody))) return; + const childPrimaryProjectId = primaryProjectIdFromIssueBody(createBody, parent.projectId ?? null); const childAssignmentScope = { - projectId: createBody.projectId ?? parent.projectId ?? null, + projectId: childPrimaryProjectId, parentIssueId: parent.id, assigneeAgentId: createBody.assigneeAgentId ?? null, assigneeUserId: createBody.assigneeUserId ?? null, @@ -5429,7 +5445,7 @@ export function issueRoutes( const sourceTrust = await sourceTrustForActorWrite({ id: issueId, companyId: parent.companyId, - projectId: createBody.projectId ?? parent.projectId ?? null, + projectId: childPrimaryProjectId, executionPolicy, }, actor); const { issue, parentBlockerAdded } = await svc.createChild(parent.id, { @@ -5580,7 +5596,7 @@ export function issueRoutes( if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, sourceIssue, childBody))) return; if (childBody.assigneeAgentId || childBody.assigneeUserId) { await assertCanAssignTasks(req, sourceIssue.companyId, { - projectId: childBody.projectId ?? sourceIssue.projectId ?? null, + projectId: primaryProjectIdFromIssueBody(childBody, sourceIssue.projectId ?? null), parentIssueId: sourceIssue.id, assigneeAgentId: childBody.assigneeAgentId ?? null, assigneeUserId: childBody.assigneeUserId ?? null, @@ -5601,7 +5617,7 @@ export function issueRoutes( const sourceTrust = await sourceTrustForActorWrite({ id: childIssueId, companyId: sourceIssue.companyId, - projectId: child.projectId ?? sourceIssue.projectId ?? null, + projectId: primaryProjectIdFromIssueBody(child, sourceIssue.projectId ?? null), executionPolicy, }, actor); normalizedChildren.push({ @@ -6101,9 +6117,10 @@ export function issueRoutes( issueId: existing.id, projectId: await resolveAssignmentProjectId({ companyId: existing.companyId, - projectId: updateFields.projectId === undefined - ? existing.projectId - : updateFields.projectId as string | null | undefined, + projectId: primaryProjectIdFromIssueBody( + updateFields as { projectId?: string | null; projectIds?: string[] }, + existing.projectId, + ), parentIssueId: (updateFields.parentId === undefined ? existing.parentId : updateFields.parentId) as string | null | undefined, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index b1c1c7216cb..c77ed567e43 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -3651,6 +3651,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const issue of selectedIssueRows) { const taskSlug = taskSlugByIssueId.get(issue.id)!; const projectSlug = issue.projectId ? (projectSlugById.get(issue.projectId) ?? null) : null; + const projectSlugs = (issue.projects ?? []) + .map((project) => projectSlugById.get(project.id) ?? null) + .filter((slug): slug is string => Boolean(slug)); // All tasks go in top-level tasks/ folder, never nested under projects/ const taskPath = `tasks/${taskSlug}/TASK.md`; const assigneeSlug = issue.assigneeAgentId ? (idToSlug.get(issue.assigneeAgentId) ?? null) : null; @@ -3684,6 +3687,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { priority: issue.priority, labelIds: issue.labelIds ?? undefined, billingCode: issue.billingCode ?? null, + projectIds: projectSlugs.length > 0 ? projectSlugs : undefined, projectWorkspaceKey: projectWorkspaceKey ?? undefined, executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined, assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined, @@ -4825,13 +4829,25 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { warnings, `Task ${manifestIssue.slug}`, ); - const projectId = manifestIssue.projectSlug + const projectIds = (manifestIssue.projectIds?.length ? manifestIssue.projectIds : ( + manifestIssue.projectSlug ? [manifestIssue.projectSlug] : [] + )) + .map((projectSlug) => + importedSlugToProjectId.get(projectSlug) + ?? existingProjectSlugToId.get(projectSlug) + ?? null + ) + .filter((id): id is string => Boolean(id)); + const projectSlugForWorkspace = manifestIssue.projectSlug ?? manifestIssue.projectIds?.[0] ?? null; + const projectId = projectIds[0] ?? ( + manifestIssue.projectSlug ? importedSlugToProjectId.get(manifestIssue.projectSlug) ?? existingProjectSlugToId.get(manifestIssue.projectSlug) ?? null - : null; - const projectWorkspaceId = manifestIssue.projectSlug && manifestIssue.projectWorkspaceKey - ? importedProjectWorkspaceIdByProjectSlug.get(manifestIssue.projectSlug)?.get(manifestIssue.projectWorkspaceKey) ?? null + : null + ); + const projectWorkspaceId = projectSlugForWorkspace && manifestIssue.projectWorkspaceKey + ? importedProjectWorkspaceIdByProjectSlug.get(projectSlugForWorkspace)?.get(manifestIssue.projectWorkspaceKey) ?? null : null; if (manifestIssue.projectWorkspaceKey && !projectWorkspaceId) { warnings.push(`Task ${manifestIssue.slug} references workspace key ${manifestIssue.projectWorkspaceKey}, but that workspace was not imported.`); @@ -4927,6 +4943,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } const createdIssue = await issues.create(targetCompany.id, { projectId, + projectIds: projectIds.length > 0 ? projectIds : undefined, projectWorkspaceId, title: manifestIssue.title, description, diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 6c2d9479e92..0b65c71f640 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -1,7 +1,7 @@ -import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import type { SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agents, companies, issues, projects } from "@paperclipai/db"; +import { agents, companies, issueProjects, issues, projects } from "@paperclipai/db"; import { COMPANY_SEARCH_MAX_LIMIT, COMPANY_SEARCH_MAX_OFFSET, @@ -44,6 +44,14 @@ type IssueSearchRow = { assigneeAgentId: string | null; assigneeUserId: string | null; projectId: string | null; + projectIds: string[] | null; + projects: Array<{ + id: string; + name: string; + color: string | null; + icon: string | null; + isPrimary: boolean; + }> | null; updatedAt: Date; score: number | string; matchedFields: string[] | null; @@ -263,6 +271,8 @@ function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: strin assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId, projectId: row.projectId, + projectIds: row.projectIds ?? [], + projects: row.projects ?? [], updatedAt: iso(row.updatedAt)!, }; const previewImageUrl = @@ -285,6 +295,43 @@ function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: strin }; } +async function hydrateIssueSearchProjects(db: Db, rows: IssueSearchRow[]) { + if (rows.length === 0) return rows; + const issueIds = rows.map((row) => row.id); + const projectRows = await db + .select({ + issueId: issueProjects.issueId, + projectId: projects.id, + name: projects.name, + color: projects.color, + icon: projects.icon, + isPrimary: issueProjects.isPrimary, + }) + .from(issueProjects) + .innerJoin(projects, and( + eq(issueProjects.projectId, projects.id), + eq(issueProjects.companyId, projects.companyId), + )) + .where(inArray(issueProjects.issueId, issueIds)) + .orderBy(desc(issueProjects.isPrimary), asc(issueProjects.createdAt), asc(issueProjects.projectId)); + const byIssueId = new Map>(); + for (const row of projectRows) { + const summaries = byIssueId.get(row.issueId) ?? []; + summaries.push({ + id: row.projectId, + name: row.name, + color: row.color, + icon: row.icon, + isPrimary: row.isPrimary, + }); + byIssueId.set(row.issueId, summaries); + } + return rows.map((row) => ({ + ...row, + projects: byIssueId.get(row.id) ?? [], + })); +} + function scoreSimpleRow(row: SimpleSearchRow, normalizedQuery: string, tokens: string[]) { const haystack = [row.title, row.description, row.role].filter(Boolean).join(" ").toLowerCase(); let score = haystack.includes(normalizedQuery) ? 90 : 0; @@ -542,6 +589,37 @@ export function companySearchService(db: Db) { assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, projectId: issues.projectId, + projectIds: sql` + ( + SELECT coalesce(array_agg(search_issue_projects.project_id::text ORDER BY search_issue_projects.is_primary DESC, search_issue_projects.created_at ASC, search_issue_projects.project_id ASC), ARRAY[]::text[]) + FROM ${issueProjects} search_issue_projects + WHERE search_issue_projects.company_id = ${companyId} + AND search_issue_projects.issue_id = ${issues.id} + ) + `, + projects: sql | null>` + ( + SELECT coalesce(jsonb_agg(jsonb_build_object( + 'id', search_projects.id::text, + 'name', search_projects.name, + 'color', search_projects.color, + 'icon', search_projects.icon, + 'isPrimary', search_issue_projects.is_primary + ) ORDER BY search_issue_projects.is_primary DESC, search_issue_projects.created_at ASC, search_issue_projects.project_id ASC), '[]'::jsonb) + FROM ${issueProjects} search_issue_projects + INNER JOIN ${projects} search_projects + ON search_projects.id = search_issue_projects.project_id + AND search_projects.company_id = search_issue_projects.company_id + WHERE search_issue_projects.company_id = ${companyId} + AND search_issue_projects.issue_id = ${issues.id} + ) + `, updatedAt: issues.updatedAt, score, matchedFields, @@ -702,8 +780,9 @@ export function companySearchService(db: Db) { }).then((result) => result.artifacts) : []; + const hydratedIssueRows = await hydrateIssueSearchProjects(db, issueRows as IssueSearchRow[]); const results: CompanySearchResult[] = [ - ...(issueRows as IssueSearchRow[]).map((row) => issueResult(row, prefix, normalizedQuery, tokens)), + ...hydratedIssueRows.map((row) => issueResult(row, prefix, normalizedQuery, tokens)), ...artifactRows.map((artifact) => artifactResult(artifact, normalizedQuery, tokens)), ...(agentRows as SimpleSearchRow[]).map((row) => { const terms = matchTerms(normalizedQuery, tokens); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 2168ffbdecd..b8e83ebf9fd 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -46,6 +46,7 @@ import type { IssueBlockedInboxAttention, IssueBlockedInboxIssueRef, IssueProductivityReview, + IssueProjectSummary, IssueProductivityReviewTrigger, IssueRelationIssueSummary, IssueWatchdogSummary, @@ -360,6 +361,7 @@ type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[]; watchdog?: IssueWatchdogSummary | null; + projects: IssueProjectSummary[]; }; type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null }; type IssueUserCommentStats = { @@ -410,6 +412,7 @@ type ProjectGoalReader = Pick; type DbReader = Pick; type IssueProjectWriter = Pick; type IssueCreateInput = Omit & { + projectIds?: string[]; labelIds?: string[]; blockedByIssueIds?: string[]; inheritExecutionWorkspaceFromIssueId?: string | null; @@ -436,6 +439,36 @@ function dedupeIssueProjectIds(projectIds: readonly string[]) { return deduped; } +function issueProjectMembershipCondition(companyId: string, projectId: string): SQL { + return sql`( + ${issues.projectId} = ${projectId} + OR EXISTS ( + SELECT 1 + FROM ${issueProjects} + WHERE ${issueProjects.companyId} = ${companyId} + AND ${issueProjects.issueId} = ${issues.id} + AND ${issueProjects.projectId} = ${projectId} + ) + )`; +} + +async function getIssueProjectIds( + tx: IssueProjectWriter, + issueId: string, +): Promise { + const rows = await tx + .select({ projectId: issueProjects.projectId }) + .from(issueProjects) + .where(eq(issueProjects.issueId, issueId)) + .orderBy(desc(issueProjects.isPrimary), asc(issueProjects.createdAt), asc(issueProjects.projectId)); + return rows.map((row) => row.projectId); +} + +function mergeLegacyPrimaryProjectId(projectId: string | null, currentProjectIds: readonly string[]) { + if (!projectId) return []; + return [projectId, ...currentProjectIds.filter((existingProjectId) => existingProjectId !== projectId)]; +} + async function validateIssueProjectIds( tx: IssueProjectWriter, companyId: string, @@ -1453,12 +1486,56 @@ async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise>(); + if (issueIds.length === 0) return map; + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + issueId: issueProjects.issueId, + projectId: projects.id, + name: projects.name, + color: projects.color, + icon: projects.icon, + isPrimary: issueProjects.isPrimary, + }) + .from(issueProjects) + .innerJoin(projects, and( + eq(issueProjects.projectId, projects.id), + eq(issueProjects.companyId, projects.companyId), + )) + .where(inArray(issueProjects.issueId, issueIdChunk)) + .orderBy(desc(issueProjects.isPrimary), asc(issueProjects.createdAt), asc(issueProjects.projectId)); + + for (const row of rows) { + const project = { + id: row.projectId, + name: row.name, + color: row.color, + icon: row.icon, + isPrimary: row.isPrimary, + }; + const existing = map.get(row.issueId); + if (existing) existing.push(project); + else map.set(row.issueId, [project]); + } + } + return map; +} + async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise { if (rows.length === 0) return []; const issueIds = rows.map((row) => row.id); - const [labelsByIssueId, watchdogByIssueId] = await Promise.all([ + const [labelsByIssueId, watchdogByIssueId, projectsByIssueId] = await Promise.all([ labelMapForIssues(dbOrTx, issueIds), watchdogMapForIssues(dbOrTx, rows), + issueProjectMapForIssues(dbOrTx, issueIds), ]); return rows.map((row) => { const issueLabels = labelsByIssueId.get(row.id) ?? []; @@ -1467,6 +1544,7 @@ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise label.id), watchdog: watchdogByIssueId.get(row.id) ?? null, + projects: projectsByIssueId.get(row.id) ?? [], }; }); } @@ -1526,7 +1604,18 @@ function lowTrustBoundaryIssueCondition( const issueIds = [...new Set(boundary.issueIds ?? [])]; const projectIds = [...new Set(boundary.projectIds ?? [])]; if (issueIds.length > 0) clauses.push(inArray(issues.id, issueIds)); - if (projectIds.length > 0) clauses.push(inArray(issues.projectId, projectIds)); + if (projectIds.length > 0) { + clauses.push(sql` + ${issues.projectId} IN (${sql.join(projectIds.map((projectId) => sql`${projectId}`), sql`, `)}) + OR EXISTS ( + SELECT 1 + FROM ${issueProjects} + WHERE ${issueProjects.companyId} = ${companyId} + AND ${issueProjects.issueId} = ${issues.id} + AND ${issueProjects.projectId} IN (${sql.join(projectIds.map((projectId) => sql`${projectId}`), sql`, `)}) + ) + `); + } if (boundary.rootIssueId) { clauses.push(sql` ${issues.id} IN ( @@ -3354,7 +3443,7 @@ async function blockedInboxIssueConditions( if (touchedByUserId) conditions.push(touchedByUserCondition(companyId, touchedByUserId)); if (inboxArchivedByUserId) conditions.push(inboxVisibleForUserCondition(companyId, inboxArchivedByUserId)); if (unreadForUserId) conditions.push(unreadForUserCondition(companyId, unreadForUserId)); - if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.projectId) conditions.push(issueProjectMembershipCondition(companyId, filters.projectId)); if (filters?.workspaceId) { conditions.push(or( eq(issues.executionWorkspaceId, filters.workspaceId), @@ -4604,7 +4693,7 @@ export function issueService(db: Db) { if (unreadForUserId) { conditions.push(unreadForUserCondition(companyId, unreadForUserId)); } - if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.projectId) conditions.push(issueProjectMembershipCondition(companyId, filters.projectId)); if (filters?.workspaceId) { conditions.push(or( eq(issues.executionWorkspaceId, filters.workspaceId), @@ -4781,7 +4870,7 @@ export function issueService(db: Db) { conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter)); } if (filters?.assigneeUserId) conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); - if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.projectId) conditions.push(issueProjectMembershipCondition(companyId, filters.projectId)); if (filters?.workspaceId) { conditions.push(or( eq(issues.executionWorkspaceId, filters.workspaceId), @@ -5121,9 +5210,16 @@ export function issueService(db: Db) { actorUserId, ...issueData } = data; + const inheritedProjectIds = + issueData.projectIds === undefined && issueData.projectId === undefined + ? await getIssueProjectIds(db, parent.id).then((projectIds) => + projectIds.length > 0 ? projectIds : (parent.projectId ? [parent.projectId] : []), + ) + : null; let child = await issueService(db).create(parent.companyId, { ...issueData, parentId: parent.id, + ...(inheritedProjectIds ? { projectIds: inheritedProjectIds } : {}), projectId: issueData.projectId ?? parent.projectId, goalId: issueData.goalId ?? parent.goalId, requestDepth: clampIssueRequestDepth( @@ -5430,6 +5526,7 @@ export function issueService(db: Db) { data: IssueCreateInput, ) => { const { + projectIds, labelIds: inputLabelIds, blockedByIssueIds, inheritExecutionWorkspaceFromIssueId, @@ -5456,6 +5553,13 @@ export function issueService(db: Db) { throw unprocessable("in_progress issues require an assignee"); } return db.transaction(async (tx) => { + const nextProjectIds = projectIds !== undefined ? dedupeIssueProjectIds(projectIds) : null; + if (nextProjectIds) { + if (nextProjectIds.length > MAX_ISSUE_PROJECTS) { + throw unprocessable(`Issues can belong to at most ${MAX_ISSUE_PROJECTS} projects`); + } + issueData.projectId = nextProjectIds[0] ?? null; + } const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId); let projectWorkspaceId = issueData.projectWorkspaceId ?? null; let executionWorkspaceId = issueData.executionWorkspaceId ?? null; @@ -5619,9 +5723,7 @@ export function issueService(db: Db) { ); const [issue] = await tx.insert(issues).values(values).returning(); - if (issue.projectId) { - await setIssueProjects(tx, issue.id, [issue.projectId]); - } + await setIssueProjects(tx, issue.id, nextProjectIds ?? (issue.projectId ? [issue.projectId] : [])); if (watchdog) { await upsertIssueWatchdogForIssue(tx, companyId, issue.id, { agentId: watchdog.agentId, @@ -5657,6 +5759,7 @@ export function issueService(db: Db) { update: async ( id: string, data: Partial & { + projectIds?: string[]; labelIds?: string[]; blockedByIssueIds?: string[]; actorAgentId?: string | null; @@ -5672,12 +5775,20 @@ export function issueService(db: Db) { if (!existing) return null; const { + projectIds, labelIds: nextLabelIds, blockedByIssueIds, actorAgentId, actorUserId, ...issueData } = data; + const nextProjectIdsExplicit = projectIds !== undefined ? dedupeIssueProjectIds(projectIds) : null; + if (nextProjectIdsExplicit && nextProjectIdsExplicit.length > MAX_ISSUE_PROJECTS) { + throw unprocessable(`Issues can belong to at most ${MAX_ISSUE_PROJECTS} projects`); + } + if (nextProjectIdsExplicit) { + issueData.projectId = nextProjectIdsExplicit[0] ?? null; + } const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; if (!isolatedWorkspacesEnabled) { delete issueData.executionWorkspaceId; @@ -5795,6 +5906,10 @@ export function issueService(db: Db) { const runUpdate = async (tx: any) => { const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId); + const currentProjectIds = + nextProjectIdsExplicit === null && issueData.projectId !== undefined + ? await getIssueProjectIds(tx, existing.id) + : []; const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([ getProjectDefaultGoalId(tx, existing.companyId, existing.projectId), getProjectDefaultGoalId( @@ -5820,8 +5935,10 @@ 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 (nextProjectIdsExplicit !== null) { + await setIssueProjects(tx, updated.id, nextProjectIdsExplicit); + } else if (issueData.projectId !== undefined || patch.projectId !== undefined) { + await setIssueProjects(tx, updated.id, mergeLegacyPrimaryProjectId(updated.projectId ?? null, currentProjectIds)); } if (nextLabelIds !== undefined) { await syncIssueLabels(updated.id, existing.companyId, nextLabelIds, tx); diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index e3bc080c179..9f811d67b8c 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -5,6 +5,7 @@ import { projectGoals, goals, issues, + issueProjects, budgetPolicies, pluginManagedResources, plugins, @@ -322,6 +323,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise>(); + for (const row of rows) { + if (!row.projectId) continue; + const issueIds = issueIdsByProjectId.get(row.projectId) ?? new Set(); + issueIds.add(row.issueId); + issueIdsByProjectId.set(row.projectId, issueIds); + } + return [...issueIdsByProjectId.entries()].map(([projectId, issueIds]) => ({ + projectId, + count: issueIds.size, + })); +} + /** * Attach lightweight list-only metrics (task count + budget) to a set of * projects using two aggregate queries (no N+1). Used by the projects list @@ -362,15 +378,28 @@ async function attachListMetrics( const projectIds = rows.map((r) => r.id); - const [taskCountRows, budgetRows] = await Promise.all([ + const [membershipTaskRows, legacyTaskRows, budgetRows] = await Promise.all([ + db + .select({ + projectId: issueProjects.projectId, + issueId: issueProjects.issueId, + }) + .from(issueProjects) + .innerJoin( + issues, + and( + eq(issueProjects.issueId, issues.id), + eq(issueProjects.companyId, issues.companyId), + ), + ) + .where(and(eq(issueProjects.companyId, companyId), inArray(issueProjects.projectId, projectIds))), db .select({ projectId: issues.projectId, - count: sql`count(*)::int`, + issueId: issues.id, }) .from(issues) - .where(and(eq(issues.companyId, companyId), inArray(issues.projectId, projectIds))) - .groupBy(issues.projectId), + .where(and(eq(issues.companyId, companyId), inArray(issues.projectId, projectIds))), db .select({ scopeId: budgetPolicies.scopeId, @@ -388,6 +417,7 @@ async function attachListMetrics( ), ), ]); + const taskCountRows = buildTaskCountRows([...membershipTaskRows, ...legacyTaskRows]); const { taskCountByProjectId, budgetByProjectId } = buildProjectListMetricMaps( taskCountRows,