Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/api/routers/prs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import {
listPRsForOrg,
listPRsForProject,
listPRsForWorkItem,
} from '../../db/repositories/prWorkItemsRepository.js';
Expand All @@ -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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SHOULD_FIX] The listPRsForOrg path is not covered by unit tests. The mock setup in prs.test.ts doesn't mock listPRsForOrg, so calling list({}) (no projectId) would hit an undefined function. Add a test case for the org-wide path and mock listPRsForOrg.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! Added listPRsForOrg to the mock setup and created a new test case "returns PRs across all projects when no projectId given" that tests the org-wide query path. The test verifies that when list({}) is called without a projectId, it calls listPRsForOrg with the org ID and skips the project access verification.

}),

forWorkItem: protectedProcedure
Expand Down
85 changes: 84 additions & 1 deletion src/db/repositories/prWorkItemsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PRSummary[]> {
const db = getDb();
Expand All @@ -139,9 +141,73 @@ export async function listPRsForProject(projectId: string): Promise<PRSummary[]>
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<PRSummary[]> {
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;
Expand All @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/api/routers/prs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}));

Expand Down Expand Up @@ -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([]);

Expand Down
2 changes: 2 additions & 0 deletions web/src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Bot,
ClipboardList,
FolderGit2,
GitPullRequest,
KeyRound,
LayoutDashboard,
Settings,
Expand All @@ -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 },
];

Expand Down
142 changes: 142 additions & 0 deletions web/src/components/prs/prs-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { ExternalLink } from 'lucide-react';

interface PR {
prNumber: number;
repoFullName: string;
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;
showRepoName?: boolean;
}

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;
const pageItems = items.slice(offset, offset + limit);

return (
<div className="space-y-4">
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">PR</th>
{showRepoName && (
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
Repository
</th>
)}
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Work Item</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Runs</th>
</tr>
</thead>
<tbody>
{pageItems.length === 0 && (
<tr>
<td
colSpan={showRepoName ? 4 : 3}
className="px-4 py-8 text-center text-muted-foreground"
>
No PRs found
</td>
</tr>
)}
{pageItems.map((item) => (
<tr
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SHOULD_FIX] key={item.prNumber} will produce duplicate React keys in the org-wide view. prNumber is only unique within a project (UNIQUE(projectId, prNumber)), so two repos can both have PR #1. This causes incorrect React reconciliation.

Fix: use a composite key:

key={`${item.repoFullName}-${item.prNumber}`}

Also consider: repoFullName is not shown in the table but is available from the backend — when viewing "All projects", users can't distinguish which repo a PR belongs to.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! Changed the React key to use a composite key ${item.repoFullName}-${item.prNumber} to ensure uniqueness across different repositories in the org-wide view.

Also added a showRepoName prop to the table that displays the repository name in a separate column when viewing "All projects" (when no project filter is active). This makes it clear which repo each PR belongs to.

key={`${item.repoFullName}-${item.prNumber}`}
className="border-b border-border transition-colors hover:bg-muted/30"
>
<td className="px-4 py-3">
{item.prUrl ? (
<a
href={item.prUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
#{item.prNumber}
{item.prTitle && <span className="ml-1 text-foreground">{item.prTitle}</span>}
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
) : (
<span className="text-muted-foreground">
#{item.prNumber}
{item.prTitle && <span className="ml-1 text-foreground">{item.prTitle}</span>}
</span>
)}
</td>
{showRepoName && (
<td className="px-4 py-3 text-muted-foreground">{item.repoFullName}</td>
)}
<td className="px-4 py-3">
{item.workItemUrl && item.workItemTitle ? (
<a
href={item.workItemUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
{item.workItemTitle}
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
) : item.workItemTitle ? (
<span>{item.workItemTitle}</span>
) : (
<span className="text-muted-foreground italic">Unlinked</span>
)}
</td>
<td className="px-4 py-3 text-right tabular-nums">{item.runCount}</td>
</tr>
))}
</tbody>
</table>
</div>

{total > limit && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
Showing {offset + 1}–{Math.min(offset + limit, total)} of {total}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onPageChange(Math.max(0, offset - limit))}
disabled={offset === 0}
className="inline-flex h-8 items-center rounded-md border border-input px-3 text-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50"
>
Previous
</button>
<span className="inline-flex h-8 items-center px-2 text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<button
type="button"
onClick={() => onPageChange(offset + limit)}
disabled={offset + limit >= total}
className="inline-flex h-8 items-center rounded-md border border-input px-3 text-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50"
>
Next
</button>
</div>
</div>
)}
</div>
);
}
Loading