From 3929b0b77fd8af820c140c6adf3c9f9b1ad1e868 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 9 Mar 2026 08:35:35 +0000 Subject: [PATCH 1/2] feat(dashboard): add PRs page with project filter and run counts --- src/api/routers/prs.ts | 11 +- src/db/repositories/prWorkItemsRepository.ts | 85 ++++++++++++- web/src/components/layout/sidebar.tsx | 2 + web/src/components/prs/prs-table.tsx | 123 +++++++++++++++++++ web/src/routes/prs.tsx | 94 ++++++++++++++ web/src/routes/route-tree.ts | 2 + 6 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 web/src/components/prs/prs-table.tsx create mode 100644 web/src/routes/prs.tsx diff --git a/src/api/routers/prs.ts b/src/api/routers/prs.ts index 8d415231..2bb9dff4 100644 --- a/src/api/routers/prs.ts +++ b/src/api/routers/prs.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { + listPRsForOrg, listPRsForProject, listPRsForWorkItem, } from '../../db/repositories/prWorkItemsRepository.js'; @@ -9,11 +10,13 @@ import { verifyProjectOrgAccess } from './_shared/projectAccess.js'; export const prsRouter = router({ list: protectedProcedure - .input(z.object({ projectId: z.string() })) + .input(z.object({ projectId: z.string().optional() })) .query(async ({ ctx, input }) => { - await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); - const prs = await listPRsForProject(input.projectId); - return prs; + if (input.projectId) { + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + return listPRsForProject(input.projectId); + } + return listPRsForOrg(ctx.effectiveOrgId); }), forWorkItem: protectedProcedure diff --git a/src/db/repositories/prWorkItemsRepository.ts b/src/db/repositories/prWorkItemsRepository.ts index 7c398910..c7ffbba4 100644 --- a/src/db/repositories/prWorkItemsRepository.ts +++ b/src/db/repositories/prWorkItemsRepository.ts @@ -123,10 +123,12 @@ export interface PRSummary { workItemId: string | null; workItemUrl: string | null; workItemTitle: string | null; + runCount: number; } /** - * Returns all PR entries for a project (with associated work item display info). + * Returns all PR entries for a project (with associated work item display info and run count). + * Optionally filter by projectId; if omitted, returns all PRs across the org. */ export async function listPRsForProject(projectId: string): Promise { const db = getDb(); @@ -139,9 +141,73 @@ export async function listPRsForProject(projectId: string): Promise workItemId: prWorkItems.workItemId, workItemUrl: prWorkItems.workItemUrl, workItemTitle: prWorkItems.workItemTitle, + runCount: countDistinct(agentRuns.id), }) .from(prWorkItems) + .leftJoin( + agentRuns, + and( + eq(agentRuns.projectId, prWorkItems.projectId), + eq(agentRuns.prNumber, prWorkItems.prNumber), + ), + ) .where(eq(prWorkItems.projectId, projectId)) + .groupBy( + prWorkItems.prNumber, + prWorkItems.repoFullName, + prWorkItems.prUrl, + prWorkItems.prTitle, + prWorkItems.workItemId, + prWorkItems.workItemUrl, + prWorkItems.workItemTitle, + ) + .orderBy(prWorkItems.prNumber); + + return rows; +} + +/** + * Returns all PR entries for an org (all projects), with associated work item display info and run count. + */ +export async function listPRsForOrg(orgId: string): Promise { + const db = getDb(); + + const projectIds = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.orgId, orgId)); + const ids = projectIds.map((p) => p.id); + if (ids.length === 0) return []; + + const rows = await db + .select({ + prNumber: prWorkItems.prNumber, + repoFullName: prWorkItems.repoFullName, + prUrl: prWorkItems.prUrl, + prTitle: prWorkItems.prTitle, + workItemId: prWorkItems.workItemId, + workItemUrl: prWorkItems.workItemUrl, + workItemTitle: prWorkItems.workItemTitle, + runCount: countDistinct(agentRuns.id), + }) + .from(prWorkItems) + .leftJoin( + agentRuns, + and( + eq(agentRuns.projectId, prWorkItems.projectId), + eq(agentRuns.prNumber, prWorkItems.prNumber), + ), + ) + .where(inArray(prWorkItems.projectId, ids)) + .groupBy( + prWorkItems.prNumber, + prWorkItems.repoFullName, + prWorkItems.prUrl, + prWorkItems.prTitle, + prWorkItems.workItemId, + prWorkItems.workItemUrl, + prWorkItems.workItemTitle, + ) .orderBy(prWorkItems.prNumber); return rows; @@ -164,9 +230,26 @@ export async function listPRsForWorkItem( workItemId: prWorkItems.workItemId, workItemUrl: prWorkItems.workItemUrl, workItemTitle: prWorkItems.workItemTitle, + runCount: countDistinct(agentRuns.id), }) .from(prWorkItems) + .leftJoin( + agentRuns, + and( + eq(agentRuns.projectId, prWorkItems.projectId), + eq(agentRuns.prNumber, prWorkItems.prNumber), + ), + ) .where(and(eq(prWorkItems.projectId, projectId), eq(prWorkItems.workItemId, workItemId))) + .groupBy( + prWorkItems.prNumber, + prWorkItems.repoFullName, + prWorkItems.prUrl, + prWorkItems.prTitle, + prWorkItems.workItemId, + prWorkItems.workItemUrl, + prWorkItems.workItemTitle, + ) .orderBy(prWorkItems.prNumber); return rows; diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 0ae68e64..af3dbf1e 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -9,6 +9,7 @@ import { Bot, ClipboardList, FolderGit2, + GitPullRequest, KeyRound, LayoutDashboard, Settings, @@ -22,6 +23,7 @@ interface SidebarProps { const mainNav = [ { to: '/' as const, label: 'Runs', icon: Activity }, { to: '/workitems' as const, label: 'Work Items', icon: ClipboardList }, + { to: '/prs' as const, label: 'PRs', icon: GitPullRequest }, { to: '/webhooklogs' as const, label: 'Webhook Logs', icon: Zap }, ]; diff --git a/web/src/components/prs/prs-table.tsx b/web/src/components/prs/prs-table.tsx new file mode 100644 index 00000000..851df34c --- /dev/null +++ b/web/src/components/prs/prs-table.tsx @@ -0,0 +1,123 @@ +import { ExternalLink } from 'lucide-react'; + +interface PR { + prNumber: number; + prUrl: string | null; + prTitle: string | null; + workItemId: string | null; + workItemUrl: string | null; + workItemTitle: string | null; + runCount: number; +} + +interface PRsTableProps { + items: PR[]; + offset: number; + limit: number; + onPageChange: (offset: number) => void; +} + +export function PRsTable({ items, offset, limit, onPageChange }: PRsTableProps) { + const total = items.length; + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(offset / limit) + 1; + const pageItems = items.slice(offset, offset + limit); + + return ( +
+
+ + + + + + + + + + {pageItems.length === 0 && ( + + + + )} + {pageItems.map((item) => ( + + + + + + ))} + +
PRWork ItemRuns
+ No PRs found +
+ {item.prUrl ? ( + + #{item.prNumber} + {item.prTitle && {item.prTitle}} + + + ) : ( + + #{item.prNumber} + {item.prTitle && {item.prTitle}} + + )} + + {item.workItemUrl && item.workItemTitle ? ( + + {item.workItemTitle} + + + ) : item.workItemTitle ? ( + {item.workItemTitle} + ) : ( + Unlinked + )} + {item.runCount}
+
+ + {total > limit && ( +
+
+ Showing {offset + 1}–{Math.min(offset + limit, total)} of {total} +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} +
+ ); +} diff --git a/web/src/routes/prs.tsx b/web/src/routes/prs.tsx new file mode 100644 index 00000000..cdbc0236 --- /dev/null +++ b/web/src/routes/prs.tsx @@ -0,0 +1,94 @@ +import { PRsTable } from '@/components/prs/prs-table.js'; +import { trpc } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; +import { createRoute, useNavigate, useSearch } from '@tanstack/react-router'; +import { z } from 'zod'; +import { rootRoute } from './__root.js'; + +const searchSchema = z.object({ + projectId: z.string().optional().catch(undefined), + offset: z.number().optional().catch(0), +}); + +function PRsPage() { + const navigate = useNavigate({ from: '/prs' }); + const search = useSearch({ from: '/prs' }); + + const projectId = search.projectId ?? ''; + const offset = search.offset ?? 0; + const limit = 50; + + const projectsQuery = useQuery(trpc.projects.list.queryOptions()); + + const prsQuery = useQuery( + trpc.prs.list.queryOptions({ + projectId: projectId || undefined, + }), + ); + + function updateSearch(updates: Record) { + navigate({ + search: (prev) => ({ + ...prev, + ...updates, + offset: updates.offset !== undefined ? Number(updates.offset) : 0, + }), + }); + } + + const selectClass = + 'h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:w-auto'; + + return ( +
+
+

PRs

+ {prsQuery.data && ( + {prsQuery.data.length} total + )} +
+ + {/* Project filter */} +
+ +
+ + {prsQuery.isLoading && ( +
Loading PRs...
+ )} + + {prsQuery.isError && ( +
+ Failed to load PRs: {prsQuery.error.message} +
+ )} + + {prsQuery.data && ( + updateSearch({ offset: newOffset })} + /> + )} +
+ ); +} + +export const prsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/prs', + component: PRsPage, + validateSearch: searchSchema, +}); diff --git a/web/src/routes/route-tree.ts b/web/src/routes/route-tree.ts index 61a51744..13e4c4d8 100644 --- a/web/src/routes/route-tree.ts +++ b/web/src/routes/route-tree.ts @@ -4,6 +4,7 @@ import { loginRoute } from './login.js'; import { gmailCallbackRoute } from './oauth/gmail-callback.js'; import { projectDetailRoute } from './projects/$projectId.js'; import { projectsIndexRoute } from './projects/index.js'; +import { prsRoute } from './prs.js'; import { runDetailRoute } from './runs/$runId.js'; import { settingsAgentsRoute } from './settings/agents.js'; import { settingsCredentialsRoute } from './settings/credentials.js'; @@ -24,5 +25,6 @@ export const routeTree = rootRoute.addChildren([ settingsDefinitionsRoute, webhookLogsRoute, workItemsRoute, + prsRoute, gmailCallbackRoute, ]); From b6df934b00740cf17e4759eaa466f7abdad05da0 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 9 Mar 2026 08:46:43 +0000 Subject: [PATCH 2/2] fix: address PR review feedback - Fix duplicate React keys by using composite key (repoFullName + prNumber) - Add repoFullName column when viewing all projects to distinguish PRs from different repos - Add missing listPRsForOrg mock and test coverage for org-wide query path Co-Authored-By: Claude Sonnet 4.5 --- tests/unit/api/routers/prs.test.ts | 17 +++++++++++++++++ web/src/components/prs/prs-table.tsx | 25 ++++++++++++++++++++++--- web/src/routes/prs.tsx | 1 + 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/tests/unit/api/routers/prs.test.ts b/tests/unit/api/routers/prs.test.ts index 75651b8e..b48bb4aa 100644 --- a/tests/unit/api/routers/prs.test.ts +++ b/tests/unit/api/routers/prs.test.ts @@ -8,11 +8,13 @@ import { createMockUser } from '../../../helpers/factories.js'; // --------------------------------------------------------------------------- const mockListPRsForProject = vi.fn(); +const mockListPRsForOrg = vi.fn(); const mockListPRsForWorkItem = vi.fn(); const mockGetRunsForPR = vi.fn(); vi.mock('../../../../src/db/repositories/prWorkItemsRepository.js', () => ({ listPRsForProject: (...args: unknown[]) => mockListPRsForProject(...args), + listPRsForOrg: (...args: unknown[]) => mockListPRsForOrg(...args), listPRsForWorkItem: (...args: unknown[]) => mockListPRsForWorkItem(...args), })); @@ -74,6 +76,21 @@ describe('prsRouter', () => { expect(mockListPRsForProject).toHaveBeenCalledWith('test-project'); }); + it('returns PRs across all projects when no projectId given', async () => { + const mockPRs = [ + mockPRSummary, + { ...mockPRSummary, prNumber: 43, repoFullName: 'owner/other-repo' }, + ]; + mockListPRsForOrg.mockResolvedValue(mockPRs); + + const caller = createCaller({ user: mockUser, effectiveOrgId: 'org-1' }); + const result = await caller.list({}); + + expect(result).toEqual(mockPRs); + expect(mockVerifyProjectOrgAccess).not.toHaveBeenCalled(); + expect(mockListPRsForOrg).toHaveBeenCalledWith('org-1'); + }); + it('returns empty array when no PRs exist', async () => { mockListPRsForProject.mockResolvedValue([]); diff --git a/web/src/components/prs/prs-table.tsx b/web/src/components/prs/prs-table.tsx index 851df34c..b6fd73a6 100644 --- a/web/src/components/prs/prs-table.tsx +++ b/web/src/components/prs/prs-table.tsx @@ -2,6 +2,7 @@ import { ExternalLink } from 'lucide-react'; interface PR { prNumber: number; + repoFullName: string; prUrl: string | null; prTitle: string | null; workItemId: string | null; @@ -15,9 +16,16 @@ interface PRsTableProps { offset: number; limit: number; onPageChange: (offset: number) => void; + showRepoName?: boolean; } -export function PRsTable({ items, offset, limit, onPageChange }: PRsTableProps) { +export function PRsTable({ + items, + offset, + limit, + onPageChange, + showRepoName = false, +}: PRsTableProps) { const total = items.length; const totalPages = Math.ceil(total / limit); const currentPage = Math.floor(offset / limit) + 1; @@ -30,6 +38,11 @@ export function PRsTable({ items, offset, limit, onPageChange }: PRsTableProps) PR + {showRepoName && ( + + Repository + + )} Work Item Runs @@ -37,14 +50,17 @@ export function PRsTable({ items, offset, limit, onPageChange }: PRsTableProps) {pageItems.length === 0 && ( - + No PRs found )} {pageItems.map((item) => ( @@ -66,6 +82,9 @@ export function PRsTable({ items, offset, limit, onPageChange }: PRsTableProps) )} + {showRepoName && ( + {item.repoFullName} + )} {item.workItemUrl && item.workItemTitle ? ( updateSearch({ offset: newOffset })} + showRepoName={!projectId} /> )}