mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
PAPA-422: surface accepted-plan decomposition state in operator UI
Add a GET endpoint and corresponding UI panel that surfaces issue_plan_decomposition records so operators can see when an accepted plan revision has been decomposed and which child ids were created, without doing raw DB forensics. - Add GET /api/issues/:id/accepted-plan-decompositions - Service helper listAcceptedPlanDecompositions enriches each record with the plan revision number and per-child summaries - New IssuePlanDecompositionsSection renders on planning issues with status, child counts, child issue links, idempotent-reuse hint, and a deep link to the plan document - Activity row labels for issue.accepted_plan_decomposition_updated describe whether children were created, reused, or both - Regression test covering listAcceptedPlanDecompositions shape Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ca0407cbb7
commit
f3ac949ca8
11 files changed
+338
No files matched your search
@@ -3475,4 +3475,41 @@ describeEmbeddedPostgres("accepted plan decomposition", () => {
|
||||
status: 409,
|
||||
});
|
||||
});
|
||||
|
||||
it("lists persisted decompositions with child issue summaries", async () => {
|
||||
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
||||
|
||||
const initial = await svc.listAcceptedPlanDecompositions(sourceIssueId);
|
||||
expect(initial).toEqual([]);
|
||||
|
||||
const result = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children: [
|
||||
{
|
||||
title: "Surface decomposition status in operator UI",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
title: "Add regression coverage",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
const decompositions = await svc.listAcceptedPlanDecompositions(sourceIssueId);
|
||||
expect(decompositions).toHaveLength(1);
|
||||
const [record] = decompositions;
|
||||
expect(record?.status).toBe("completed");
|
||||
expect(record?.acceptedPlanRevisionId).toBe(acceptedPlanRevisionId);
|
||||
expect(record?.acceptedPlanRevisionNumber).toBeTypeOf("number");
|
||||
expect(record?.childIssues.map((child) => child.id).sort()).toEqual(
|
||||
[...result.childIssueIds].sort(),
|
||||
);
|
||||
expect(record?.childIssues.every((child) => typeof child.title === "string")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3333,6 +3333,18 @@ export function issueRoutes(
|
||||
res.status(201).json(issue);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/accepted-plan-decompositions", async (req, res) => {
|
||||
const sourceIssueId = req.params.id as string;
|
||||
const sourceIssue = await svc.getById(sourceIssueId);
|
||||
if (!sourceIssue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, sourceIssue.companyId);
|
||||
const decompositions = await svc.listAcceptedPlanDecompositions(sourceIssue.id);
|
||||
res.json(decompositions);
|
||||
});
|
||||
|
||||
router.post("/issues/:id/accepted-plan-decompositions", validate(createAcceptedPlanDecompositionSchema), async (req, res) => {
|
||||
const sourceIssueId = req.params.id as string;
|
||||
const sourceIssue = await svc.getById(sourceIssueId);
|
||||
|
||||
@@ -4376,6 +4376,67 @@ export function issueService(db: Db) {
|
||||
};
|
||||
},
|
||||
|
||||
listAcceptedPlanDecompositions: async (sourceIssueId: string) => {
|
||||
const sourceIssue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, sourceIssueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!sourceIssue) return [];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
decomposition: issuePlanDecompositions,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
})
|
||||
.from(issuePlanDecompositions)
|
||||
.leftJoin(
|
||||
documentRevisions,
|
||||
eq(documentRevisions.id, issuePlanDecompositions.acceptedPlanRevisionId),
|
||||
)
|
||||
.where(and(
|
||||
eq(issuePlanDecompositions.companyId, sourceIssue.companyId),
|
||||
eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id),
|
||||
))
|
||||
.orderBy(desc(issuePlanDecompositions.createdAt));
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const allChildIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const childId of normalizeIssuePlanDecompositionChildIds(row.decomposition.childIssueIds)) {
|
||||
allChildIds.add(childId);
|
||||
}
|
||||
}
|
||||
|
||||
const childIssueRows = allChildIds.size > 0
|
||||
? await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, sourceIssue.companyId), inArray(issues.id, Array.from(allChildIds))))
|
||||
: [];
|
||||
const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row]));
|
||||
|
||||
return rows.map((row) => {
|
||||
const childIds = normalizeIssuePlanDecompositionChildIds(row.decomposition.childIssueIds);
|
||||
return {
|
||||
...row.decomposition,
|
||||
acceptedPlanRevisionNumber: row.revisionNumber ?? null,
|
||||
childIssues: childIds
|
||||
.map((childId) => childIssueMap.get(childId) ?? null)
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
create: async (
|
||||
companyId: string,
|
||||
data: IssueCreateInput,
|
||||
|
||||
Reference in new issue
Block a user