Skip to content

Commit d90c331

Browse files
committed
feat: add pull request dashboard with organization and technology filters
Signed-off-by: Arnab Nandy <arnab_nandy7@yahoo.com>
1 parent 77f2b04 commit d90c331

29 files changed

Lines changed: 4739 additions & 230 deletions

File tree

‎doc/architecture.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,60 @@ flowchart LR
6464
Drizzle <--> Turso
6565
```
6666

67+
## Pull request discovery
68+
69+
`/pull-requests` provides a separate public PR dashboard with organization,
70+
technology, optional repository, status, and sort filters stored in the URL.
71+
`GET /api/pull-requests` returns basic results before enrichment. An exact
72+
repository replaces the organization qualifier, since combining GitHub scope
73+
qualifiers can broaden results. Open excludes drafts; closed excludes merged PRs.
74+
75+
Languages use a bundled GitHub Linguist name/alias catalog and repository language
76+
qualifiers. Frameworks use repository topics
77+
without imposing an additional language. Discovery covers up to 20 recently
78+
updated matching repositories within the organization, with visible coverage
79+
notices. An exact repository is checked independently of that cap. Repository
80+
groups are merged in sort order before pagination, with at most three search
81+
requests running concurrently. Searches expose up to ten pages of 24 results.
82+
83+
`POST /api/pull-requests/enrichment` accepts up to 24 PR references. It batches
84+
review information through GraphQL and enriches up to 12 unique repositories,
85+
three at a time. Shared repository services preserve the issue board's two-hour
86+
metadata cache and six-hour responsiveness and documentation caches. Optional
87+
failures remain Unknown; private repository and PR details are not returned.
88+
All optional enrichment is skipped without the server's `GITHUB_TOKEN` to protect
89+
the shared unauthenticated quota. This does not require visitor login. Linked issue
90+
counts cover only public nodes in the first ten links; token-authorized connection
91+
totals are never exposed. The shared REST helper permits only the GitHub HTTPS API
92+
origin and refuses redirects.
93+
94+
`GET /api/pull-requests/details` loads change statistics and mergeability when a
95+
card expands. Basic results remain usable while insights load. Changing filters
96+
or navigating away aborts stale client requests. Search, enrichment, and detail
97+
requests have separate application rate-limit buckets. No new tables are needed.
98+
99+
Performance should be measured separately for initial search and enrichment,
100+
with warm and cold caches and different numbers of unique repositories. No
101+
production latency target has been established. Saved PR searches and notifications
102+
are deferred.
103+
104+
Active PR cards include an explainable readiness score: non-draft state (20),
105+
review decision (30), CI checks (25), absence of merge conflicts (15), and review
106+
size (10). Approved reviews receive 30 points, required reviews 10, and changes
107+
requested 0. Passing checks receive 25, pending/expected checks 5, and failures or
108+
errors 0. Changes up to 200 lines and 5 files receive 10 size points; up to 1,000
109+
lines and 20 files receive 5; larger changes receive 0. These weights are product
110+
heuristics for review readiness and effort, not code-quality judgments.
111+
112+
Drafts are capped at 39. Changes requested, failing checks, or conflicts cap the
113+
score at 49. Missing signals produce a minimum–maximum range with weighted signal
114+
coverage rather than a failure penalty. Complete scores of 80 or more are labeled
115+
Ready; other unblocked complete scores need attention. Merged/closed PRs are N/A.
116+
Change statistics and mergeability are scalar fields on the existing batched
117+
GraphQL request, so scoring adds no HTTP requests and does not delay basic search.
118+
Without batch details, expanding a card can refine its score using the existing
119+
on-demand detail request.
120+
67121
## Issue discovery
68122

69123
`GET /api/search` validates and rate-limits requests before querying GitHub. Languages use GitHub language qualifiers; framework and ecosystem terms such as React, Next.js, Spring Boot, and Kubernetes use repository topics.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { NextResponse } from "next/server";
2+
import { REPOSITORY_PATTERN } from "@/features/pull-requests/filters";
3+
import { getPullRequestDetails } from "@/features/pull-requests/server/enrichment";
4+
import {
5+
limitPullRequestRequest,
6+
pullRequestApiError,
7+
} from "@/features/pull-requests/server/api";
8+
9+
export async function GET(request: Request) {
10+
const params = new URL(request.url).searchParams;
11+
const repository = params.get("repository") ?? "";
12+
const number = Number(params.get("number"));
13+
if (
14+
!REPOSITORY_PATTERN.test(repository) ||
15+
!Number.isSafeInteger(number) ||
16+
number < 1
17+
)
18+
return NextResponse.json(
19+
{ error: "Provide a valid repository and PR number." },
20+
{ status: 400 },
21+
);
22+
const limited = limitPullRequestRequest(request, "details");
23+
if (limited) return limited;
24+
try {
25+
return NextResponse.json(await getPullRequestDetails(repository, number));
26+
} catch (error) {
27+
return pullRequestApiError(error);
28+
}
29+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { NextResponse } from "next/server";
2+
import { REPOSITORY_PATTERN } from "@/features/pull-requests/filters";
3+
import {
4+
enrichPullRequests,
5+
type EnrichmentReference,
6+
} from "@/features/pull-requests/server/enrichment";
7+
import {
8+
limitPullRequestRequest,
9+
pullRequestApiError,
10+
} from "@/features/pull-requests/server/api";
11+
12+
export async function POST(request: Request) {
13+
const limited = limitPullRequestRequest(request, "enrichment");
14+
if (limited) return limited;
15+
let body: unknown;
16+
try {
17+
const text = await request.text();
18+
if (text.length > 12000)
19+
return NextResponse.json(
20+
{ error: "Request is too large." },
21+
{ status: 400 },
22+
);
23+
body = JSON.parse(text);
24+
} catch {
25+
return NextResponse.json({ error: "Invalid JSON." }, { status: 400 });
26+
}
27+
if (
28+
!Array.isArray(body) ||
29+
body.length < 1 ||
30+
body.length > 24 ||
31+
!body.every(
32+
(reference) =>
33+
reference &&
34+
typeof reference.id === "string" &&
35+
/^[a-zA-Z0-9_=-]{1,200}$/.test(reference.id) &&
36+
typeof reference.repository === "string" &&
37+
REPOSITORY_PATTERN.test(reference.repository),
38+
)
39+
) {
40+
return NextResponse.json(
41+
{ error: "Provide 1–24 valid pull request references." },
42+
{ status: 400 },
43+
);
44+
}
45+
try {
46+
return NextResponse.json(
47+
await enrichPullRequests(body as EnrichmentReference[]),
48+
);
49+
} catch (error) {
50+
return pullRequestApiError(error);
51+
}
52+
}

‎src/app/api/pull-requests/route.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { NextResponse } from "next/server";
2+
import {
3+
readPullRequestFilters,
4+
validatePullRequestFilters,
5+
} from "@/features/pull-requests/filters";
6+
import { searchPullRequests } from "@/features/pull-requests/server/search";
7+
import {
8+
limitPullRequestRequest,
9+
pullRequestApiError,
10+
} from "@/features/pull-requests/server/api";
11+
12+
export async function GET(request: Request) {
13+
const filters = readPullRequestFilters(new URL(request.url).searchParams);
14+
const error = validatePullRequestFilters(filters);
15+
if (error) return NextResponse.json({ error }, { status: 400 });
16+
const limited = limitPullRequestRequest(request, "search");
17+
if (limited) return limited;
18+
try {
19+
return NextResponse.json(await searchPullRequests(filters));
20+
} catch (error) {
21+
return pullRequestApiError(error);
22+
}
23+
}

‎src/app/pull-requests/page.tsx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Suspense } from "react";
2+
import type { Metadata } from "next";
3+
import { PullRequestDashboard } from "@/features/pull-requests/components/pull-request-dashboard";
4+
5+
export const metadata: Metadata = {
6+
title: "Pull Request Finder | OpenIssue.dev",
7+
description:
8+
"Search public GitHub pull requests by organization and technology, with repository insights and review status.",
9+
};
10+
11+
export default function PullRequestsPage() {
12+
return (
13+
<Suspense
14+
fallback={
15+
<main className="p-8">
16+
<output>Loading pull request dashboard…</output>
17+
</main>
18+
}
19+
>
20+
<PullRequestDashboard />
21+
</Suspense>
22+
);
23+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Link from "next/link";
2+
import { CircleDot, GitPullRequest } from "lucide-react";
3+
import { cn } from "@/lib/utils";
4+
5+
export function DashboardNavigation({
6+
current,
7+
}: Readonly<{ current: "issues" | "pull-requests" }>) {
8+
return (
9+
<nav
10+
aria-label="Dashboards"
11+
className="flex w-fit items-center gap-1 rounded-lg border bg-background p-1 text-sm"
12+
>
13+
{[
14+
{ href: "/", value: "issues", label: "Issues", Icon: CircleDot },
15+
{
16+
href: "/pull-requests",
17+
value: "pull-requests",
18+
label: "Pull requests",
19+
Icon: GitPullRequest,
20+
},
21+
].map(({ href, value, label, Icon }) => (
22+
<Link
23+
key={value}
24+
href={href}
25+
aria-current={current === value ? "page" : undefined}
26+
className={cn(
27+
"inline-flex items-center gap-2 rounded-md px-3 py-2 hover:bg-muted",
28+
current === value && "bg-muted font-medium",
29+
)}
30+
>
31+
<Icon className="size-4" />
32+
{label}
33+
</Link>
34+
))}
35+
</nav>
36+
);
37+
}

‎src/features/issues/components/issue-finder.tsx‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
useState,
1010
} from "react";
1111
import Image from "next/image";
12+
import { DashboardNavigation } from "@/components/dashboard-navigation";
1213
import { Bookmark, Mail, Search, Sparkles, Trash2 } from "lucide-react";
1314
import { ThemeToggle } from "@/components/theme-toggle";
1415
import { AuthControls } from "@/components/auth-controls";
@@ -1065,11 +1066,6 @@ function getSelectedContentTab(activeTab: ContentTab, authenticated: boolean) {
10651066
return authenticated ? activeTab : "results";
10661067
}
10671068

1068-
function getTokenStatus(data: SearchResponse | null) {
1069-
if (!data) return "unknown";
1070-
return data.tokenConfigured ? "configured" : "not set";
1071-
}
1072-
10731069
function getRetryHandler(
10741070
errorSource: "search" | "loadMore" | null,
10751071
search: () => void,
@@ -1115,7 +1111,6 @@ function SearchOverview({
11151111
scope,
11161112
responsiveness,
11171113
data,
1118-
tokenStatus,
11191114
}: Readonly<{
11201115
label: string;
11211116
sort: string;
@@ -1126,7 +1121,6 @@ function SearchOverview({
11261121
scope: string;
11271122
responsiveness: string;
11281123
data: SearchResponse | null;
1129-
tokenStatus: string;
11301124
}>) {
11311125
return (
11321126
<Card className="self-end">
@@ -1151,7 +1145,6 @@ function SearchOverview({
11511145
label="Raw GitHub matches"
11521146
value={data ? compactNumber(data.totalCount) : "-"}
11531147
/>
1154-
<Metric label="GitHub token" value={tokenStatus} />
11551148
</CardContent>
11561149
</Card>
11571150
);
@@ -1747,7 +1740,6 @@ export function IssueFinder() {
17471740
}
17481741
}
17491742

1750-
const tokenStatus = getTokenStatus(data);
17511743
const handleRetry = getRetryHandler(
17521744
errorSource,
17531745
() => void searchIssues(),
@@ -1784,6 +1776,7 @@ export function IssueFinder() {
17841776
<ThemeToggle />
17851777
</div>
17861778
</div>
1779+
<DashboardNavigation current="issues" />
17871780
<div className="max-w-3xl space-y-4">
17881781
<h1 className="text-4xl font-semibold tracking-normal text-foreground sm:text-5xl">
17891782
Find active open-source issues by tech.
@@ -1981,7 +1974,6 @@ export function IssueFinder() {
19811974
scope={selectedScope.label}
19821975
responsiveness={selectedResponsiveness.label}
19831976
data={data}
1984-
tokenStatus={tokenStatus}
19851977
/>
19861978
</div>
19871979
</section>

0 commit comments

Comments
 (0)