diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 113c3f36957..deeae205334 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -116,6 +116,7 @@ export interface IssueRelationIssueSummary { priority: IssuePriority; assigneeAgentId: string | null; assigneeUserId: string | null; + terminalBlockers?: IssueRelationIssueSummary[]; } export type IssueBlockerAttentionState = "none" | "covered" | "needs_attention"; diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 05ea80c4e94..c8be8ff1ac4 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1401,6 +1401,49 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]); }); + it("adds terminal blockers to immediate blocked-by summaries", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const issueA = randomUUID(); + const issueB = randomUUID(); + const issueC = randomUUID(); + const issueD = randomUUID(); + await db.insert(issues).values([ + { id: issueA, companyId, identifier: "PAP-1", title: "Issue A", status: "blocked", priority: "medium" }, + { id: issueB, companyId, identifier: "PAP-2", title: "Issue B", status: "blocked", priority: "medium" }, + { id: issueC, companyId, identifier: "PAP-3", title: "Issue C", status: "blocked", priority: "medium" }, + { id: issueD, companyId, identifier: "PAP-4", title: "Issue D", status: "todo", priority: "high" }, + ]); + + await svc.update(issueC, { blockedByIssueIds: [issueD] }); + await svc.update(issueB, { blockedByIssueIds: [issueC] }); + await svc.update(issueA, { blockedByIssueIds: [issueB] }); + + const relations = await svc.getRelationSummaries(issueA); + + expect(relations.blockedBy).toHaveLength(1); + expect(relations.blockedBy[0]).toMatchObject({ + id: issueB, + identifier: "PAP-2", + title: "Issue B", + terminalBlockers: [ + expect.objectContaining({ + id: issueD, + identifier: "PAP-4", + title: "Issue D", + status: "todo", + priority: "high", + }), + ], + }); + }); + it("rejects blocking cycles", async () => { const companyId = randomUUID(); await db.insert(companies).values({ diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 7948cb26099..01bd1d7c69b 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -763,6 +763,109 @@ function appendBlockerAttentionEdges( } } +type IssueRelationSummaryRow = { + relatedId: string; + identifier: string | null; + title: string; + status: string; + priority: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; +}; + +function summarizeIssueRelationRow(row: IssueRelationSummaryRow): IssueRelationIssueSummary { + return { + id: row.relatedId, + identifier: row.identifier, + title: row.title, + status: row.status as IssueRelationIssueSummary["status"], + priority: row.priority as IssueRelationIssueSummary["priority"], + assigneeAgentId: row.assigneeAgentId, + assigneeUserId: row.assigneeUserId, + }; +} + +async function terminalExplicitBlockersByRoot( + companyId: string, + roots: IssueRelationIssueSummary[], + dbOrTx: DbReader, +): Promise> { + const rootIds = [...new Set(roots.map((root) => root.id))]; + const terminalByRoot = new Map(); + if (rootIds.length === 0) return terminalByRoot; + + const nodesById = new Map(); + const edgesByIssueId = new Map(); + for (const root of roots) nodesById.set(root.id, root); + + let frontier = rootIds; + for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) { + const nextFrontier = new Set(); + for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + currentIssueId: issueRelations.relatedIssueId, + relatedId: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where( + and( + eq(issueRelations.companyId, companyId), + eq(issueRelations.type, "blocks"), + inArray(issueRelations.relatedIssueId, chunk), + eq(issues.companyId, companyId), + ne(issues.status, "done"), + ), + ); + + for (const row of rows) { + const existingEdges = edgesByIssueId.get(row.currentIssueId) ?? []; + if (!existingEdges.includes(row.relatedId)) { + existingEdges.push(row.relatedId); + edgesByIssueId.set(row.currentIssueId, existingEdges); + } + if (!nodesById.has(row.relatedId)) { + nodesById.set(row.relatedId, summarizeIssueRelationRow(row)); + nextFrontier.add(row.relatedId); + } + } + } + + if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) break; + frontier = [...nextFrontier]; + } + + const collectTerminal = (issueId: string, seen: Set): IssueRelationIssueSummary[] => { + if (seen.has(issueId)) return []; + const node = nodesById.get(issueId); + if (!node || node.status === "done") return []; + const nextSeen = new Set(seen); + nextSeen.add(issueId); + const downstreamIds = edgesByIssueId.get(issueId) ?? []; + if (downstreamIds.length === 0) return [node]; + return downstreamIds.flatMap((downstreamId) => collectTerminal(downstreamId, nextSeen)); + }; + + for (const rootId of rootIds) { + const deduped = new Map(); + for (const blocker of collectTerminal(rootId, new Set())) { + if (blocker.id !== rootId) deduped.set(blocker.id, blocker); + } + if (deduped.size > 0) { + terminalByRoot.set(rootId, [...deduped.values()].sort((a, b) => a.title.localeCompare(b.title))); + } + } + + return terminalByRoot; +} + async function listIssueBlockerAttentionMap( dbOrTx: any, companyId: string, @@ -1425,30 +1528,26 @@ export function issueService(db: Db) { ]); for (const row of blockedByRows) { - empty.get(row.currentIssueId)?.blockedBy.push({ - id: row.relatedId, - identifier: row.identifier, - title: row.title, - status: row.status as IssueRelationIssueSummary["status"], - priority: row.priority as IssueRelationIssueSummary["priority"], - assigneeAgentId: row.assigneeAgentId, - assigneeUserId: row.assigneeUserId, - }); + empty.get(row.currentIssueId)?.blockedBy.push(summarizeIssueRelationRow(row)); } for (const row of blockingRows) { - empty.get(row.currentIssueId)?.blocks.push({ - id: row.relatedId, - identifier: row.identifier, - title: row.title, - status: row.status as IssueRelationIssueSummary["status"], - priority: row.priority as IssueRelationIssueSummary["priority"], - assigneeAgentId: row.assigneeAgentId, - assigneeUserId: row.assigneeUserId, - }); + empty.get(row.currentIssueId)?.blocks.push(summarizeIssueRelationRow(row)); } + const terminalByRoot = await terminalExplicitBlockersByRoot( + companyId, + [...empty.values()].flatMap((relations) => relations.blockedBy), + dbOrTx, + ); + for (const relations of empty.values()) { relations.blockedBy.sort((a, b) => a.title.localeCompare(b.title)); + for (const blocker of relations.blockedBy) { + const terminalBlockers = terminalByRoot.get(blocker.id); + if (terminalBlockers && terminalBlockers.length > 0) { + blocker.terminalBlockers = terminalBlockers; + } + } relations.blocks.sort((a, b) => a.title.localeCompare(b.title)); } diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 0527fefcdf3..653379802ce 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -403,6 +403,59 @@ describe("IssueChatThread", () => { }); }); + it("shows terminal blocker context when an immediate blocker is transitively blocked", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).toContain("PAP-2167"); + expect(container.textContent).toContain("Phase 7 review"); + expect(container.textContent).toContain("Ultimately waiting on"); + expect(container.textContent).toContain("PAP-2201"); + expect(container.textContent).toContain("Security sign-off"); + expect(container.querySelector('[data-issue-path-id="PAP-2201"]')).not.toBeNull(); + + act(() => { + root.unmount(); + }); + }); + it("shows paused assigned agent context above the composer", () => { const root = createRoot(container); const pausedAgent = { diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 26d75ea8577..e10b8cc33bc 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -354,6 +354,26 @@ function IssueBlockedNotice({ if (blockers.length === 0 && issueStatus !== "blocked") return null; const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues"; + const terminalBlockers = blockers + .flatMap((blocker) => blocker.terminalBlockers ?? []) + .filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index); + + const renderBlockerChip = (blocker: IssueRelationIssueSummary) => { + const issuePathId = blocker.identifier ?? blocker.id; + return ( + + {blocker.identifier ?? blocker.id.slice(0, 8)} + + {blocker.title} + + + ); + }; return (
@@ -367,22 +387,15 @@ function IssueBlockedNotice({

{blockers.length > 0 ? (
- {blockers.map((blocker) => { - const issuePathId = blocker.identifier ?? blocker.id; - return ( - - {blocker.identifier ?? blocker.id.slice(0, 8)} - - {blocker.title} - - - ); - })} + {blockers.map(renderBlockerChip)} +
+ ) : null} + {terminalBlockers.length > 0 ? ( +
+ + Ultimately waiting on + + {terminalBlockers.map(renderBlockerChip)}
) : null}