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:
Devin FoleyandPaperclip committed 2026-05-24 18:09:58 -07:00
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);
});
});
+12
View File
@@ -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);
+61
View File
@@ -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,