mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Add multi-project issue API write path
This commit is contained in:
1 parent
7a1724b6ac
commit
43184bb5fa
15 files changed
+519
-31
No files matched your search
@@ -652,6 +652,7 @@ export type {
|
||||
IssueExecutionMonitorState,
|
||||
IssueRelation,
|
||||
IssueRelationIssueSummary,
|
||||
IssueProjectSummary,
|
||||
IssueExecutionPolicy,
|
||||
IssueExecutionState,
|
||||
IssueExecutionStage,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -377,6 +377,7 @@ export type {
|
||||
IssueAncestorGoal,
|
||||
IssueAttachment,
|
||||
IssueLabel,
|
||||
IssueProjectSummary,
|
||||
IssueWatchdog,
|
||||
IssueWatchdogStatus,
|
||||
IssueWatchdogSummary,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -375,6 +375,7 @@ function withCreateIssueStatusDefault<T extends z.ZodRawShape>(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(),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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(
|
||||
[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, NonNullable<IssueSearchRow["projects"]>>();
|
||||
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<string[] | null>`
|
||||
(
|
||||
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<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
isPrimary: boolean;
|
||||
}> | 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);
|
||||
|
||||
+127
-10
@@ -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<Db, "select">;
|
||||
type DbReader = Pick<Db, "select">;
|
||||
type IssueProjectWriter = Pick<Db, "select" | "insert" | "update" | "delete">;
|
||||
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
|
||||
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<boolean> {
|
||||
return sql<boolean>`(
|
||||
${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<string[]> {
|
||||
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<Map<s
|
||||
return map;
|
||||
}
|
||||
|
||||
async function issueProjectMapForIssues(dbOrTx: any, issueIds: string[]) {
|
||||
const map = new Map<string, Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
isPrimary: boolean;
|
||||
}>>();
|
||||
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<IssueWithLabels[]> {
|
||||
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<IssueWith
|
||||
labels: issueLabels,
|
||||
labelIds: issueLabels.map((label) => 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<boolean>`
|
||||
${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<boolean>`
|
||||
${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<typeof issues.$inferInsert> & {
|
||||
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<typeof issues.$inferSelect>) => 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);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
projectGoals,
|
||||
goals,
|
||||
issues,
|
||||
issueProjects,
|
||||
budgetPolicies,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
@@ -322,6 +323,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
||||
}
|
||||
|
||||
type TaskCountRow = { projectId: string | null; count: number };
|
||||
type TaskProjectIssueRow = { projectId: string | null; issueId: string };
|
||||
type ProjectBudgetRow = { scopeId: string; amount: number; windowKind: string };
|
||||
|
||||
/**
|
||||
@@ -348,6 +350,20 @@ export function buildProjectListMetricMaps(taskCountRows: TaskCountRow[], budget
|
||||
return { taskCountByProjectId, budgetByProjectId };
|
||||
}
|
||||
|
||||
export function buildTaskCountRows(rows: TaskProjectIssueRow[]): TaskCountRow[] {
|
||||
const issueIdsByProjectId = new Map<string, Set<string>>();
|
||||
for (const row of rows) {
|
||||
if (!row.projectId) continue;
|
||||
const issueIds = issueIdsByProjectId.get(row.projectId) ?? new Set<string>();
|
||||
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<number>`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,
|
||||
|
||||
Reference in new issue
Block a user