Add multi-project issue API write path

This commit is contained in:
Dotta committed 2026-07-05 16:20:30 +00:00
1 parent 7a1724b6ac
commit 43184bb5fa
15 files changed
+519 -31

No files matched your search

+1
View File
@@ -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;
+1
View File
@@ -377,6 +377,7 @@ export type {
IssueAncestorGoal,
IssueAttachment,
IssueLabel,
IssueProjectSummary,
IssueWatchdog,
IssueWatchdogStatus,
IssueWatchdogSummary,
+10
View File
@@ -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[];
+3
View File
@@ -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(),
+1
View File
@@ -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, {
+168
View File
@@ -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(
[],
+26 -9
View File
@@ -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,
+21 -4
View File
@@ -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,
+82 -3
View File
@@ -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
View File
@@ -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);
+34 -4
View File
@@ -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,