diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index c8be8ff1ac4..6a116db2ffb 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -895,6 +895,66 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { ); }); + it("includes blockedBy summaries on list rows in one batched pass", async () => { + const companyId = randomUUID(); + const blockerId = randomUUID(); + const blockedId = randomUUID(); + const unblockedId = 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: blockerId, + companyId, + title: "Blocker issue", + status: "todo", + priority: "high", + }, + { + id: blockedId, + companyId, + title: "Blocked issue", + status: "blocked", + priority: "medium", + }, + { + id: unblockedId, + companyId, + title: "Unblocked issue", + status: "todo", + priority: "medium", + }, + ]); + + await db.insert(issueRelations).values({ + companyId, + issueId: blockerId, + relatedIssueId: blockedId, + type: "blocks", + }); + + const result = await svc.list(companyId); + const byId = new Map(result.map((issue) => [issue.id, issue])); + + expect(byId.get(blockedId)?.blockedBy).toEqual([ + expect.objectContaining({ + id: blockerId, + identifier: null, + title: "Blocker issue", + status: "todo", + priority: "high", + }), + ]); + expect(byId.get(blockerId)?.blockedBy).toEqual([]); + expect(byId.get(unblockedId)?.blockedBy).toEqual([]); + }); + it("trims list payload fields that can grow large on issue index routes", async () => { const companyId = randomUUID(); const issueId = randomUUID(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 01bd1d7c69b..d70977e579b 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1296,6 +1296,62 @@ async function lastActivityStatsForIssues( return [...byIssueId.values()]; } +async function blockedByMapForIssues( + dbOrTx: any, + companyId: string, + issueIds: string[], +): Promise> { + const map = new Map(); + const uniqueIssueIds = [...new Set(issueIds)]; + for (const issueId of uniqueIssueIds) { + map.set(issueId, []); + } + if (uniqueIssueIds.length === 0) return map; + + for (const issueIdChunk of chunkList(uniqueIssueIds, 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, issueIdChunk), + ), + ); + + for (const row of rows) { + const blockedBy = map.get(row.currentIssueId); + if (!blockedBy) continue; + 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, + }); + } + } + + for (const blockedBy of map.values()) { + blockedBy.sort((a, b) => a.title.localeCompare(b.title)); + } + + return map; +} + export function issueService(db: Db) { const instanceSettings = instanceSettingsService(db); const treeControlSvc = issueTreeControlService(db); @@ -1914,7 +1970,7 @@ export function issueService(db: Db) { } const issueIds = withRuns.map((row) => row.id); - const [statsRows, readRows, lastActivityRows] = await Promise.all([ + const [statsRows, readRows, lastActivityRows, blockedByMap] = await Promise.all([ contextUserId ? userCommentStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), @@ -1922,6 +1978,7 @@ export function issueService(db: Db) { ? userReadStatsForIssues(db, companyId, contextUserId, issueIds) : Promise.resolve([]), lastActivityStatsForIssues(db, companyId, issueIds), + blockedByMapForIssues(db, companyId, issueIds), ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); @@ -1937,6 +1994,7 @@ export function issueService(db: Db) { ) ?? row.updatedAt; return { ...row, + blockedBy: blockedByMap.get(row.id) ?? [], lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), }; @@ -1954,6 +2012,7 @@ export function issueService(db: Db) { ) ?? row.updatedAt; return { ...row, + blockedBy: blockedByMap.get(row.id) ?? [], lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), ...deriveIssueUserContext(row, contextUserId, { diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index f5da510b646..5dc015813f8 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -24,7 +24,7 @@ interface IssueRowProps { trailingMeta?: ReactNode; titleSuffix?: ReactNode; titleClassName?: string; - checklistStepNumber?: number | null; + checklistStepNumber?: number | string | null; checklistCurrentStep?: boolean; checklistDependencyChips?: ReactNode; checklistRowId?: string; @@ -84,7 +84,7 @@ export function IssueRow({ > {hasChecklistStep ? (