Show descendant issue tree on issue detail

This commit is contained in:
Dotta committed 2026-04-24 00:21:07 -05:00
1 parent b0dde400a6
commit 144efe900d
7 files changed
+144 -3

No files matched your search

+104
View File
@@ -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();
+1
View File
@@ -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,
+24
View File
@@ -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<string>([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));
+8
View File
@@ -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 });
+2
View File
@@ -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));
+2
View File
@@ -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,
+3 -3
View File
@@ -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[]>(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}