mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Show descendant issue tree on issue detail
This commit is contained in:
1 parent
b0dde400a6
commit
144efe900d
7 files changed
+144
-3
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user