diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index b21c603e2eb..05ea80c4e94 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -355,6 +355,110 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([commentMatchId, descriptionMatchId]); }); + it("filters issue lists to the full descendant tree for a root issue", async () => { + const companyId = randomUUID(); + const rootId = randomUUID(); + const childId = randomUUID(); + const grandchildId = randomUUID(); + const siblingId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: rootId, + companyId, + title: "Root", + status: "todo", + priority: "medium", + }, + { + id: childId, + companyId, + parentId: rootId, + title: "Child", + status: "todo", + priority: "medium", + }, + { + id: grandchildId, + companyId, + parentId: childId, + title: "Grandchild", + status: "todo", + priority: "medium", + }, + { + id: siblingId, + companyId, + title: "Sibling", + status: "todo", + priority: "medium", + }, + ]); + + const result = await svc.list(companyId, { descendantOf: rootId }); + + expect(new Set(result.map((issue) => issue.id))).toEqual(new Set([childId, grandchildId])); + }); + + it("combines descendant filtering with search", async () => { + const companyId = randomUUID(); + const rootId = randomUUID(); + const childId = randomUUID(); + const grandchildId = randomUUID(); + const outsideMatchId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: rootId, + companyId, + title: "Root", + status: "todo", + priority: "medium", + }, + { + id: childId, + companyId, + parentId: rootId, + title: "Relevant parent", + status: "todo", + priority: "medium", + }, + { + id: grandchildId, + companyId, + parentId: childId, + title: "Needle grandchild", + status: "todo", + priority: "medium", + }, + { + id: outsideMatchId, + companyId, + title: "Needle outside", + status: "todo", + priority: "medium", + }, + ]); + + const result = await svc.list(companyId, { descendantOf: rootId, q: "needle" }); + + expect(result.map((issue) => issue.id)).toEqual([grandchildId]); + }); + it("accepts issue identifiers through getById", async () => { const companyId = randomUUID(); const issueId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index aef722aa3af..ee7b5088acc 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -831,6 +831,7 @@ export function issueRoutes( workspaceId: req.query.workspaceId as string | undefined, executionWorkspaceId: req.query.executionWorkspaceId as string | undefined, parentId: req.query.parentId as string | undefined, + descendantOf: req.query.descendantOf as string | undefined, labelId: req.query.labelId as string | undefined, originKind: req.query.originKind as string | undefined, originId: req.query.originId as string | undefined, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 013d0e9b51b..5046ba09233 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -106,6 +106,7 @@ export interface IssueFilters { workspaceId?: string; executionWorkspaceId?: string; parentId?: string; + descendantOf?: string; labelId?: string; originKind?: string; originId?: string; @@ -1396,6 +1397,29 @@ export function issueService(db: Db) { AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\' ) `; + if (filters?.descendantOf) { + const descendantIds: string[] = []; + const seen = new Set([filters.descendantOf]); + let frontier = [filters.descendantOf]; + + while (frontier.length > 0) { + const children = await db + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.companyId, companyId), inArray(issues.parentId, frontier))); + + frontier = []; + for (const child of children) { + if (seen.has(child.id)) continue; + seen.add(child.id); + descendantIds.push(child.id); + frontier.push(child.id); + } + } + + if (descendantIds.length === 0) return []; + conditions.push(inArray(issues.id, descendantIds)); + } if (filters?.status) { const statuses = filters.status.split(",").map((s) => s.trim()); conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses)); diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index a32add42e68..dedbfa9cd73 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -24,6 +24,14 @@ describe("issuesApi.list", () => { ); }); + it("passes descendantOf through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { descendantOf: "issue-root-1", limit: 25 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?descendantOf=issue-root-1&limit=25", + ); + }); + it("passes generic workspaceId filters through to the company issues endpoint", async () => { await issuesApi.list("company-1", { workspaceId: "workspace-1", limit: 1000 }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index b2856513747..d7e244b25c1 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -43,6 +43,7 @@ export const issuesApi = { executionWorkspaceId?: string; originKind?: string; originId?: string; + descendantOf?: string; includeRoutineExecutions?: boolean; q?: string; limit?: number; @@ -63,6 +64,7 @@ export const issuesApi = { if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId); if (filters?.originKind) params.set("originKind", filters.originKind); if (filters?.originId) params.set("originId", filters.originId); + if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf); if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); if (filters?.q) params.set("q", filters.q); if (filters?.limit) params.set("limit", String(filters.limit)); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index c345b87afc8..c61b216a2fd 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -41,6 +41,8 @@ export const queryKeys = { ["issues", companyId, "project", projectId] as const, listByParent: (companyId: string, parentId: string) => ["issues", companyId, "parent", parentId] as const, + listByDescendantRoot: (companyId: string, rootIssueId: string) => + ["issues", companyId, "descendants", rootIssueId] as const, listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) => ["issues", companyId, "execution-workspace", executionWorkspaceId] as const, detail: (id: string) => ["issues", "detail", id] as const, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a6e1830034c..58840877519 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1132,9 +1132,9 @@ export function IssueDetail() { const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({ queryKey: issue?.id && resolvedCompanyId - ? queryKeys.issues.listByParent(resolvedCompanyId, issue.id) + ? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id) : ["issues", "parent", "pending"], - queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.id }), + queryFn: () => issuesApi.list(resolvedCompanyId!, { descendantOf: issue!.id }), enabled: !!resolvedCompanyId && !!issue?.id, placeholderData: keepPreviousDataForSameQueryTail(issue?.id ?? "pending"), }); @@ -3133,7 +3133,7 @@ export function IssueDetail() { projectId={issue.projectId ?? undefined} viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`} issueLinkState={resolvedIssueDetailState ?? location.state} - searchFilters={{ parentId: issue.id }} + searchFilters={{ descendantOf: issue.id }} baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)} createIssueLabel="Sub-issue" onUpdateIssue={handleChildIssueUpdate}