From d90c33189fbeeb4e27578ad64d4cb07da11fd276 Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Sun, 13 Sep 2026 16:07:22 +0530 Subject: [PATCH 01/10] feat: add pull request dashboard with organization and technology filters Signed-off-by: Arnab Nandy --- doc/architecture.md | 54 + src/app/api/pull-requests/details/route.ts | 29 + src/app/api/pull-requests/enrichment/route.ts | 52 + src/app/api/pull-requests/route.ts | 23 + src/app/pull-requests/page.tsx | 23 + src/components/dashboard-navigation.tsx | 37 + .../issues/components/issue-finder.tsx | 12 +- src/features/issues/server/github-search.ts | 218 +-- src/features/pull-requests/client.ts | 27 + .../components/pull-request-card.tsx | 390 +++++ .../components/pull-request-dashboard.tsx | 372 +++++ src/features/pull-requests/data/README.md | 13 + .../pull-requests/data/languages.json | 1271 +++++++++++++++++ src/features/pull-requests/filters.ts | 58 + src/features/pull-requests/scoring.ts | 203 +++ src/features/pull-requests/server/api.ts | 33 + .../pull-requests/server/enrichment.ts | 255 ++++ src/features/pull-requests/server/search.ts | 254 ++++ src/features/pull-requests/types.ts | 71 + .../repositories/server/github-repository.ts | 141 ++ src/lib/github.ts | 121 ++ .../issues/components/issue-finder.test.tsx | 10 +- .../issues/server/github-search.test.ts | 4 +- tests/features/pull-requests/api.test.ts | 136 ++ .../features/pull-requests/dashboard.test.tsx | 355 +++++ .../features/pull-requests/enrichment.test.ts | 304 ++++ tests/features/pull-requests/scoring.test.ts | 143 ++ tests/features/pull-requests/search.test.ts | 264 ++++ tests/lib/github.test.ts | 96 ++ 29 files changed, 4739 insertions(+), 230 deletions(-) create mode 100644 src/app/api/pull-requests/details/route.ts create mode 100644 src/app/api/pull-requests/enrichment/route.ts create mode 100644 src/app/api/pull-requests/route.ts create mode 100644 src/app/pull-requests/page.tsx create mode 100644 src/components/dashboard-navigation.tsx create mode 100644 src/features/pull-requests/client.ts create mode 100644 src/features/pull-requests/components/pull-request-card.tsx create mode 100644 src/features/pull-requests/components/pull-request-dashboard.tsx create mode 100644 src/features/pull-requests/data/README.md create mode 100644 src/features/pull-requests/data/languages.json create mode 100644 src/features/pull-requests/filters.ts create mode 100644 src/features/pull-requests/scoring.ts create mode 100644 src/features/pull-requests/server/api.ts create mode 100644 src/features/pull-requests/server/enrichment.ts create mode 100644 src/features/pull-requests/server/search.ts create mode 100644 src/features/pull-requests/types.ts create mode 100644 src/features/repositories/server/github-repository.ts create mode 100644 src/lib/github.ts create mode 100644 tests/features/pull-requests/api.test.ts create mode 100644 tests/features/pull-requests/dashboard.test.tsx create mode 100644 tests/features/pull-requests/enrichment.test.ts create mode 100644 tests/features/pull-requests/scoring.test.ts create mode 100644 tests/features/pull-requests/search.test.ts create mode 100644 tests/lib/github.test.ts diff --git a/doc/architecture.md b/doc/architecture.md index 2381fcf..e93a72e 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -64,6 +64,60 @@ flowchart LR Drizzle <--> Turso ``` +## Pull request discovery + +`/pull-requests` provides a separate public PR dashboard with organization, +technology, optional repository, status, and sort filters stored in the URL. +`GET /api/pull-requests` returns basic results before enrichment. An exact +repository replaces the organization qualifier, since combining GitHub scope +qualifiers can broaden results. Open excludes drafts; closed excludes merged PRs. + +Languages use a bundled GitHub Linguist name/alias catalog and repository language +qualifiers. Frameworks use repository topics +without imposing an additional language. Discovery covers up to 20 recently +updated matching repositories within the organization, with visible coverage +notices. An exact repository is checked independently of that cap. Repository +groups are merged in sort order before pagination, with at most three search +requests running concurrently. Searches expose up to ten pages of 24 results. + +`POST /api/pull-requests/enrichment` accepts up to 24 PR references. It batches +review information through GraphQL and enriches up to 12 unique repositories, +three at a time. Shared repository services preserve the issue board's two-hour +metadata cache and six-hour responsiveness and documentation caches. Optional +failures remain Unknown; private repository and PR details are not returned. +All optional enrichment is skipped without the server's `GITHUB_TOKEN` to protect +the shared unauthenticated quota. This does not require visitor login. Linked issue +counts cover only public nodes in the first ten links; token-authorized connection +totals are never exposed. The shared REST helper permits only the GitHub HTTPS API +origin and refuses redirects. + +`GET /api/pull-requests/details` loads change statistics and mergeability when a +card expands. Basic results remain usable while insights load. Changing filters +or navigating away aborts stale client requests. Search, enrichment, and detail +requests have separate application rate-limit buckets. No new tables are needed. + +Performance should be measured separately for initial search and enrichment, +with warm and cold caches and different numbers of unique repositories. No +production latency target has been established. Saved PR searches and notifications +are deferred. + +Active PR cards include an explainable readiness score: non-draft state (20), +review decision (30), CI checks (25), absence of merge conflicts (15), and review +size (10). Approved reviews receive 30 points, required reviews 10, and changes +requested 0. Passing checks receive 25, pending/expected checks 5, and failures or +errors 0. Changes up to 200 lines and 5 files receive 10 size points; up to 1,000 +lines and 20 files receive 5; larger changes receive 0. These weights are product +heuristics for review readiness and effort, not code-quality judgments. + +Drafts are capped at 39. Changes requested, failing checks, or conflicts cap the +score at 49. Missing signals produce a minimum–maximum range with weighted signal +coverage rather than a failure penalty. Complete scores of 80 or more are labeled +Ready; other unblocked complete scores need attention. Merged/closed PRs are N/A. +Change statistics and mergeability are scalar fields on the existing batched +GraphQL request, so scoring adds no HTTP requests and does not delay basic search. +Without batch details, expanding a card can refine its score using the existing +on-demand detail request. + ## Issue discovery `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. diff --git a/src/app/api/pull-requests/details/route.ts b/src/app/api/pull-requests/details/route.ts new file mode 100644 index 0000000..7511898 --- /dev/null +++ b/src/app/api/pull-requests/details/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { REPOSITORY_PATTERN } from "@/features/pull-requests/filters"; +import { getPullRequestDetails } from "@/features/pull-requests/server/enrichment"; +import { + limitPullRequestRequest, + pullRequestApiError, +} from "@/features/pull-requests/server/api"; + +export async function GET(request: Request) { + const params = new URL(request.url).searchParams; + const repository = params.get("repository") ?? ""; + const number = Number(params.get("number")); + if ( + !REPOSITORY_PATTERN.test(repository) || + !Number.isSafeInteger(number) || + number < 1 + ) + return NextResponse.json( + { error: "Provide a valid repository and PR number." }, + { status: 400 }, + ); + const limited = limitPullRequestRequest(request, "details"); + if (limited) return limited; + try { + return NextResponse.json(await getPullRequestDetails(repository, number)); + } catch (error) { + return pullRequestApiError(error); + } +} diff --git a/src/app/api/pull-requests/enrichment/route.ts b/src/app/api/pull-requests/enrichment/route.ts new file mode 100644 index 0000000..b106f9f --- /dev/null +++ b/src/app/api/pull-requests/enrichment/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { REPOSITORY_PATTERN } from "@/features/pull-requests/filters"; +import { + enrichPullRequests, + type EnrichmentReference, +} from "@/features/pull-requests/server/enrichment"; +import { + limitPullRequestRequest, + pullRequestApiError, +} from "@/features/pull-requests/server/api"; + +export async function POST(request: Request) { + const limited = limitPullRequestRequest(request, "enrichment"); + if (limited) return limited; + let body: unknown; + try { + const text = await request.text(); + if (text.length > 12000) + return NextResponse.json( + { error: "Request is too large." }, + { status: 400 }, + ); + body = JSON.parse(text); + } catch { + return NextResponse.json({ error: "Invalid JSON." }, { status: 400 }); + } + if ( + !Array.isArray(body) || + body.length < 1 || + body.length > 24 || + !body.every( + (reference) => + reference && + typeof reference.id === "string" && + /^[a-zA-Z0-9_=-]{1,200}$/.test(reference.id) && + typeof reference.repository === "string" && + REPOSITORY_PATTERN.test(reference.repository), + ) + ) { + return NextResponse.json( + { error: "Provide 1–24 valid pull request references." }, + { status: 400 }, + ); + } + try { + return NextResponse.json( + await enrichPullRequests(body as EnrichmentReference[]), + ); + } catch (error) { + return pullRequestApiError(error); + } +} diff --git a/src/app/api/pull-requests/route.ts b/src/app/api/pull-requests/route.ts new file mode 100644 index 0000000..33b3af0 --- /dev/null +++ b/src/app/api/pull-requests/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { + readPullRequestFilters, + validatePullRequestFilters, +} from "@/features/pull-requests/filters"; +import { searchPullRequests } from "@/features/pull-requests/server/search"; +import { + limitPullRequestRequest, + pullRequestApiError, +} from "@/features/pull-requests/server/api"; + +export async function GET(request: Request) { + const filters = readPullRequestFilters(new URL(request.url).searchParams); + const error = validatePullRequestFilters(filters); + if (error) return NextResponse.json({ error }, { status: 400 }); + const limited = limitPullRequestRequest(request, "search"); + if (limited) return limited; + try { + return NextResponse.json(await searchPullRequests(filters)); + } catch (error) { + return pullRequestApiError(error); + } +} diff --git a/src/app/pull-requests/page.tsx b/src/app/pull-requests/page.tsx new file mode 100644 index 0000000..6b777f9 --- /dev/null +++ b/src/app/pull-requests/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { PullRequestDashboard } from "@/features/pull-requests/components/pull-request-dashboard"; + +export const metadata: Metadata = { + title: "Pull Request Finder | OpenIssue.dev", + description: + "Search public GitHub pull requests by organization and technology, with repository insights and review status.", +}; + +export default function PullRequestsPage() { + return ( + + Loading pull request dashboard… + + } + > + + + ); +} diff --git a/src/components/dashboard-navigation.tsx b/src/components/dashboard-navigation.tsx new file mode 100644 index 0000000..b93c267 --- /dev/null +++ b/src/components/dashboard-navigation.tsx @@ -0,0 +1,37 @@ +import Link from "next/link"; +import { CircleDot, GitPullRequest } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export function DashboardNavigation({ + current, +}: Readonly<{ current: "issues" | "pull-requests" }>) { + return ( + + ); +} diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index f3c26bd..899a6c6 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -9,6 +9,7 @@ import { useState, } from "react"; import Image from "next/image"; +import { DashboardNavigation } from "@/components/dashboard-navigation"; import { Bookmark, Mail, Search, Sparkles, Trash2 } from "lucide-react"; import { ThemeToggle } from "@/components/theme-toggle"; import { AuthControls } from "@/components/auth-controls"; @@ -1065,11 +1066,6 @@ function getSelectedContentTab(activeTab: ContentTab, authenticated: boolean) { return authenticated ? activeTab : "results"; } -function getTokenStatus(data: SearchResponse | null) { - if (!data) return "unknown"; - return data.tokenConfigured ? "configured" : "not set"; -} - function getRetryHandler( errorSource: "search" | "loadMore" | null, search: () => void, @@ -1115,7 +1111,6 @@ function SearchOverview({ scope, responsiveness, data, - tokenStatus, }: Readonly<{ label: string; sort: string; @@ -1126,7 +1121,6 @@ function SearchOverview({ scope: string; responsiveness: string; data: SearchResponse | null; - tokenStatus: string; }>) { return ( @@ -1151,7 +1145,6 @@ function SearchOverview({ label="Raw GitHub matches" value={data ? compactNumber(data.totalCount) : "-"} /> - ); @@ -1747,7 +1740,6 @@ export function IssueFinder() { } } - const tokenStatus = getTokenStatus(data); const handleRetry = getRetryHandler( errorSource, () => void searchIssues(), @@ -1784,6 +1776,7 @@ export function IssueFinder() { +

Find active open-source issues by tech. @@ -1981,7 +1974,6 @@ export function IssueFinder() { scope={selectedScope.label} responsiveness={selectedResponsiveness.label} data={data} - tokenStatus={tokenStatus} />

diff --git a/src/features/issues/server/github-search.ts b/src/features/issues/server/github-search.ts index ee71462..ddd21a4 100644 --- a/src/features/issues/server/github-search.ts +++ b/src/features/issues/server/github-search.ts @@ -1,3 +1,7 @@ +import { githubFetch } from "@/lib/github"; +import { getRepositoryResponsiveness, getCommunityProfile } from "@/features/repositories/server/github-repository"; +export { RateLimitError, isRateLimitError } from "@/lib/github"; +export { getRepositoryResponsiveness } from "@/features/repositories/server/github-repository"; import { CONTRIBUTION_TYPE_FILTERS, EXPERIENCE_FILTERS, @@ -24,10 +28,7 @@ import { import { scoreRepositoryHealth } from "@/features/issues/lib/repository-health"; import { getResponsivenessBoost, - scoreRepositoryResponsiveness, unknownRepositoryResponsiveness, - type ResponsivenessIssue, - type ResponsivenessPullRequest, } from "@/features/issues/lib/repository-responsiveness"; import type { GitHubIssue, @@ -50,66 +51,6 @@ const REPO_ISSUE_BATCH_SIZE = 10; const RESPONSIVENESS_REPOSITORY_LIMIT = 12; const COMMUNITY_PROFILE_REPOSITORY_LIMIT = 12; -export class RateLimitError extends Error { - retryAfterSeconds: number | null; - - constructor(message: string, retryAfterSeconds: number | null = null) { - super(message); - this.name = "RateLimitError"; - this.retryAfterSeconds = retryAfterSeconds; - } -} - -export function isRateLimitError(error: unknown): error is RateLimitError { - return error instanceof RateLimitError; -} - -type GitHubCommunityProfileResponse = { - health_percentage: number; - files: Partial< - Record< - | "readme" - | "contributing" - | "license" - | "code_of_conduct" - | "issue_template" - | "pull_request_template", - { html_url?: string | null } | null - > - >; -}; - -type GitHubResponsivenessResponse = { - data?: { - repository?: { - issues: { nodes: ResponsivenessIssue[] }; - pullRequests: { nodes: ResponsivenessPullRequest[] }; - } | null; - }; - errors?: Array<{ message: string }>; -}; - -const RESPONSIVENESS_QUERY = ` - query RepositoryResponsiveness($owner: String!, $name: String!, $since: DateTime!) { - repository(owner: $owner, name: $name) { - issues(first: 20, orderBy: { field: CREATED_AT, direction: DESC }, filterBy: { since: $since }) { - nodes { - author { login } - closedAt - createdAt - labels(first: 10) { nodes { name } } - comments(first: 20) { - nodes { author { login } authorAssociation createdAt } - } - } - } - pullRequests(first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { - nodes { authorAssociation createdAt mergedAt } - } - } - } -`; - function normalize(value: string | null) { return (value ?? "").trim().toLowerCase(); } @@ -391,74 +332,6 @@ function summarizeEnrichment(issues: Issue[], signal: keyof IssueEnrichment) { return "partial" as const; } -async function githubFetch(url: string, token?: string, revalidate = 60) { - const response = await fetch(url, { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - next: { revalidate }, - }); - - if (!response.ok) { - const body = await response.text(); - const retryAfterSeconds = computeRetryAfterSeconds(response.headers); - - if ( - (response.status === 403 || response.status === 429) && - isRateLimitResponse(body) - ) { - throw new RateLimitError( - "GitHub API rate limit exceeded. Please wait a few minutes and try again.", - retryAfterSeconds, - ); - } - - throw new Error(`GitHub API error ${response.status}: ${body}`); - } - - return { - data: (await response.json()) as T, - rateLimitRemaining: response.headers.get("x-ratelimit-remaining"), - }; -} - -function isRateLimitResponse(body: string): boolean { - const lower = body.toLowerCase(); - return ( - lower.includes("rate limit") || - lower.includes("rate_limit") || - lower.includes("api rate limit exceeded") || - lower.includes("secondary rate limit") - ); -} - -// GitHub's primary rate-limit responses commonly omit `retry-after` and -// instead provide `x-ratelimit-reset`, a Unix timestamp (seconds) for when -// the limit resets. Fall back to computing the delay from that header so we -// don't under-report the wait time with a default cooldown. -function computeRetryAfterSeconds(headers: Headers): number | null { - const retryAfter = headers.get("retry-after"); - if (retryAfter) { - const parsed = Number.parseInt(retryAfter, 10); - if (!Number.isNaN(parsed)) { - return parsed; - } - } - - const resetHeader = headers.get("x-ratelimit-reset"); - if (resetHeader) { - const resetEpochSeconds = Number.parseInt(resetHeader, 10); - if (!Number.isNaN(resetEpochSeconds)) { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - return Math.max(0, resetEpochSeconds - nowEpochSeconds); - } - } - - return null; -} - async function buildSearchScope(tech: string, token?: string) { const repoTopicQuery = buildRepoTopicQuery(tech); const queryParts = ["is:issue", "is:open", "archived:false"]; @@ -504,89 +377,6 @@ function getSearchTotalCount( return searchResults[0]?.data.total_count ?? 0; } -export async function getRepositoryResponsiveness( - fullName: string, - token = process.env.GITHUB_TOKEN, -) { - if (!token) { - return unknownRepositoryResponsiveness( - "GitHub token required for responsiveness analysis", - ); - } - - const [owner, name] = fullName.split("/"); - if (!owner || !name) return unknownRepositoryResponsiveness(); - - const sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); - sinceDate.setUTCHours(Math.floor(sinceDate.getUTCHours() / 6) * 6, 0, 0, 0); - const since = sinceDate.toISOString(); - const response = await fetch("https://api.github.com/graphql", { - method: "POST", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - }, - body: JSON.stringify({ - query: RESPONSIVENESS_QUERY, - variables: { owner, name, since }, - }), - next: { revalidate: 21600 }, - }); - - if (!response.ok) { - const body = await response.text(); - const retryAfterSeconds = computeRetryAfterSeconds(response.headers); - - if ( - (response.status === 403 || response.status === 429) && - isRateLimitResponse(body) - ) { - throw new RateLimitError( - "GitHub API rate limit exceeded. Please wait a few minutes and try again.", - retryAfterSeconds, - ); - } - - throw new Error(`GitHub GraphQL error ${response.status}`); - } - const payload = (await response.json()) as GitHubResponsivenessResponse; - const repository = payload.data?.repository; - - if (!repository || payload.errors?.length) { - throw new Error( - payload.errors?.[0]?.message ?? "Repository analytics unavailable", - ); - } - - return scoreRepositoryResponsiveness( - repository.issues.nodes, - repository.pullRequests.nodes, - ); -} - -async function getCommunityProfile(fullName: string, token?: string) { - const result = await githubFetch( - `https://api.github.com/repos/${fullName}/community/profile`, - token, - 21600, - ); - const files = result.data.files ?? {}; - - return { - healthPercentage: result.data.health_percentage, - documentation: { - readme: files.readme?.html_url ?? null, - contributing: files.contributing?.html_url ?? null, - license: files.license?.html_url ?? null, - codeOfConduct: files.code_of_conduct?.html_url ?? null, - issueTemplate: files.issue_template?.html_url ?? null, - pullRequestTemplate: files.pull_request_template?.html_url ?? null, - }, - }; -} - export async function searchGitHubRepositories( query: string, ): Promise { diff --git a/src/features/pull-requests/client.ts b/src/features/pull-requests/client.ts new file mode 100644 index 0000000..688dace --- /dev/null +++ b/src/features/pull-requests/client.ts @@ -0,0 +1,27 @@ +export class PullRequestClientError extends Error { + constructor( + message: string, + public retryAfter: number | null = null, + ) { + super(message); + } +} + +export async function fetchPullRequestData( + url: string, + options: RequestInit, +): Promise { + const response = await fetch(url, options); + const payload = await response.json(); + if (!response.ok) { + const retryAfter = + response.status === 429 + ? Math.max(1, Number(payload.retryAfter ?? 60) || 60) + : null; + throw new PullRequestClientError( + payload.error ?? "Unable to load GitHub data.", + retryAfter, + ); + } + return payload as T; +} diff --git a/src/features/pull-requests/components/pull-request-card.tsx b/src/features/pull-requests/components/pull-request-card.tsx new file mode 100644 index 0000000..75e1fbd --- /dev/null +++ b/src/features/pull-requests/components/pull-request-card.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + ArrowUpRight, + GitPullRequest, + MessageCircle, + Star, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { compactNumber, relativeDate } from "@/features/issues/lib/format"; +import { fetchPullRequestData } from "../client"; +import { scorePullRequest } from "../scoring"; +import type { + PullRequest, + PullRequestDetails, + PullRequestReview, + RepositoryInsights, +} from "../types"; + +const DOCUMENTS = { + readme: "README", + contributing: "Contributing guide", + license: "License", + codeOfConduct: "Code of conduct", + issueTemplate: "Issue template", + pullRequestTemplate: "PR template", +} as const; +const REVIEW_LABELS: Record = { + APPROVED: "Approved", + CHANGES_REQUESTED: "Changes requested", + REVIEW_REQUIRED: "Review required", +}; +const CHECK_LABELS: Record = { + SUCCESS: "Passing", + FAILURE: "Failing", + ERROR: "Error", + PENDING: "Pending", + EXPECTED: "Expected", +}; + +function RepositorySummary({ + insights, + loading, +}: Readonly<{ insights?: RepositoryInsights; loading: boolean }>) { + if (!insights && loading) + return ( +

+ Loading repository insights… +

+ ); + return ( +
+
+ + + {insights?.stars == null + ? "Stars unknown" + : compactNumber(insights.stars)} + + + Repository health:{" "} + {insights?.health.score == null + ? "Unknown" + : `${insights.health.score} · ${insights.health.label}`} + + + Repository responsiveness:{" "} + {insights?.responsiveness.status ?? "Unknown"} + + {insights?.hacktoberfest && ( + Hacktoberfest repository topic + )} +
+
+ + Repository signals and contribution guides + +
+ {insights && ( +
    + {[ + ...insights.health.signals, + ...insights.responsiveness.signals, + ].map((signal, index) => ( +
  • {signal}
  • + ))} +
+ )} +
+ {Object.entries(DOCUMENTS).map(([key, label]) => { + const url = + insights?.documentation?.[key as keyof typeof DOCUMENTS]; + return url ? ( + + {label} + + ) : ( + + {label}:{" "} + {insights?.documentation ? "Not provided" : "Unknown"} + + ); + })} +
+
+
+
+ ); +} + +function mergeabilityLabel( + status: PullRequest["status"], + mergeable: boolean | null, +) { + if (status === "merged" || status === "closed") return "Not applicable"; + if (mergeable === null) return "Unknown"; + return mergeable ? "No conflicts" : "Conflicts"; +} + +function readinessLabel(score: ReturnType) { + if (score.minimum === null) return "N/A"; + if (score.minimum === score.maximum) return `${score.minimum}/100`; + return `${score.minimum}–${score.maximum}/100`; +} + +function ScoreSummary({ + score, +}: Readonly<{ score: ReturnType }>) { + return ( +
+
+ PR readiness: {readinessLabel(score)} + {score.label} +
+ {score.minimum !== null && score.coverage < 100 && ( +

+ {score.coverage}% signal coverage. Unknown signals create a range; + they are not failures. +

+ )} +
+ + How this score works + +

{score.explanation}

+
    + {score.signals.map((signal) => ( +
  • + {signal.label}:{" "} + {signal.points === null + ? `Unknown (up to ${signal.maximum} points)` + : `${signal.points}/${signal.maximum}`}{" "} + — {signal.reason} +
  • + ))} +
+
+
+ ); +} + +function ReviewSummary({ + review, + enriching, + assignees, +}: Readonly<{ + review?: PullRequestReview | null; + enriching: boolean; + assignees: string[]; +}>) { + return ( +
+ {enriching && review === undefined ? ( +

Loading reviews and checks…

+ ) : ( + <> +
+ + Review:{" "} + {review?.reviewDecision + ? (REVIEW_LABELS[review.reviewDecision] ?? + review.reviewDecision) + : "Unknown"} + + + Checks:{" "} + {review?.checks + ? (CHECK_LABELS[review.checks] ?? review.checks) + : "Unknown"} + +
+

+ Requested reviewers:{" "} + {review ? review.reviewers.join(", ") || "None" : "Unknown"} +

+

+ Linked closing issues (public sample):{" "} + {review ? review.linkedIssueCount : "Unknown"} +

+ {review?.linkedIssues.map((issue) => ( + + {issue.title} + + ))} + {review && ( +

+ Showing public issues from the first 10 linked issues. View the PR + for more. +

+ )} + + )} +

+ Assignees: {assignees.join(", ") || "None"} +

+
+ ); +} + +export function PullRequestCard({ + pullRequest: pr, + insights, + review, + enriching, +}: Readonly<{ + pullRequest: PullRequest; + insights?: RepositoryInsights; + review?: PullRequestReview | null; + enriching: boolean; +}>) { + const [expanded, setExpanded] = useState(false); + const [details, setDetails] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const controller = useRef(null); + const score = scorePullRequest(pr.status, review, details); + useEffect(() => () => controller.current?.abort(), []); + + async function openDetails() { + setExpanded((value) => !value); + if (expanded || details || loading) return; + if (review?.details) { + setDetails(review.details); + return; + } + controller.current = new AbortController(); + setLoading(true); + setError(""); + try { + setDetails( + await fetchPullRequestData( + `/api/pull-requests/details?${new URLSearchParams({ repository: pr.repository, number: String(pr.number) })}`, + { signal: controller.current.signal }, + ), + ); + } catch (error) { + if (!controller.current.signal.aborted) + setError( + error instanceof Error ? error.message : "Details unavailable.", + ); + } finally { + if (!controller.current.signal.aborted) setLoading(false); + } + } + + return ( + + +
+ + {pr.repository} + + + + {pr.status} + +
+ + + {pr.title}{" "} + + #{pr.number} + + + +

+ By {pr.author} · Updated {relativeDate(pr.updatedAt)} · Created{" "} + {relativeDate(pr.createdAt)} +

+
+ {pr.labels.map((label) => ( + + {label} + + ))} +
+
+ + + + +
+ + + {pr.comments} comments + +
+ + +
+
+ {expanded && ( +
+ {loading &&

Loading change details…

} + {error && ( +

{error} Close and reopen details to retry.

+ )} + {details && ( +
+ {details.changedFiles} changed files + + +{details.additions} + + + −{details.deletions} + + + Mergeability:{" "} + {mergeabilityLabel(pr.status, details.mergeable)} + +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/features/pull-requests/components/pull-request-dashboard.tsx b/src/features/pull-requests/components/pull-request-dashboard.tsx new file mode 100644 index 0000000..177548c --- /dev/null +++ b/src/features/pull-requests/components/pull-request-dashboard.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useEffect, useRef, useState, type SubmitEvent } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { GitPullRequest, Search } from "lucide-react"; +import { DashboardNavigation } from "@/components/dashboard-navigation"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { AuthControls } from "@/components/auth-controls"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { LoadingResults } from "@/features/issues/components/loading-results"; +import { PullRequestCard } from "./pull-request-card"; +import { fetchPullRequestData, PullRequestClientError } from "../client"; +import { + DEFAULT_PR_FILTERS, + readPullRequestFilters, + validatePullRequestFilters, + pullRequestSearchParams, +} from "../filters"; +import type { + PullRequestEnrichment, + PullRequestFilters, + PullRequestSearchResponse, +} from "../types"; + +const SELECT_CLASS = + "h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-ring"; + +function SearchSession({ query }: Readonly<{ query: string }>) { + const router = useRouter(); + const [filters, setFilters] = useState(() => + query + ? readPullRequestFilters(new URLSearchParams(query)) + : DEFAULT_PR_FILTERS, + ); + const [data, setData] = useState(null); + const [enrichment, setEnrichment] = useState( + null, + ); + const [error, setError] = useState(""); + const [enrichmentError, setEnrichmentError] = useState(""); + const [loading, setLoading] = useState(!!query); + const [enriching, setEnriching] = useState(false); + const [attempt, setAttempt] = useState(0); + const [cooldown, setCooldown] = useState(null); + const controller = useRef(null); + + useEffect(() => { + if (cooldown === null) return; + const timer = setTimeout(() => setCooldown(null), cooldown * 1000); + return () => clearTimeout(timer); + }, [cooldown]); + + useEffect(() => { + if (!query) return; + const abort = new AbortController(); + controller.current = abort; + async function loadInsights(result: PullRequestSearchResponse) { + setEnriching(true); + try { + const insights = await fetchPullRequestData( + "/api/pull-requests/enrichment", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + result.pullRequests.map(({ id, repository }) => ({ + id, + repository, + })), + ), + signal: abort.signal, + }, + ); + if (!abort.signal.aborted) setEnrichment(insights); + } catch (error) { + if (!abort.signal.aborted) + setEnrichmentError( + error instanceof Error ? error.message : "Insights unavailable.", + ); + } finally { + if (!abort.signal.aborted) setEnriching(false); + } + } + async function run() { + const validation = validatePullRequestFilters( + readPullRequestFilters(new URLSearchParams(query)), + ); + setError(validation ?? ""); + if (validation) { + setLoading(false); + return; + } + setLoading(true); + setEnrichment(null); + setEnrichmentError(""); + try { + const result = await fetchPullRequestData( + `/api/pull-requests?${query}`, + { signal: abort.signal }, + ); + if (abort.signal.aborted) return; + setData(result); + setLoading(false); + if (!result.pullRequests.length) return; + await loadInsights(result); + } catch (error) { + if (abort.signal.aborted) return; + setError(error instanceof Error ? error.message : "Search failed."); + if (error instanceof PullRequestClientError) + setCooldown(error.retryAfter); + setLoading(false); + } + } + void run(); + return () => abort.abort(); + }, [query, attempt]); + + function updateFilter( + key: K, + value: PullRequestFilters[K], + ) { + controller.current?.abort(); + setLoading(false); + setEnriching(false); + setFilters((current) => ({ ...current, [key]: value, page: 1 })); + } + + function navigate(next: PullRequestFilters) { + const validation = validatePullRequestFilters(next); + if (validation) { + setError(validation); + return; + } + const params = pullRequestSearchParams(next).toString(); + if (params === query) setAttempt((value) => value + 1); + else router.push(`/pull-requests?${params}`, { scroll: false }); + } + + function submit(event: SubmitEvent) { + event.preventDefault(); + navigate({ ...filters, page: 1 }); + } + + return ( + <> +
+ + + + + +
+ +
+
+

+ Technology matches the repository’s language or topic. Public + repositories only. +

+ {error && ( +
+

{error}

+ {cooldown !== null ? ( +

Search is paused for {cooldown} seconds.

+ ) : ( + + )} +
+ )} + {loading && ( +
+ Searching pull requests + +
+ )} + {!loading && !data && !error && ( +
+ +

+ Explore pull requests across an organization +

+

+ Choose an organization and technology to start searching. +

+
+ )} + {data && !loading && ( +
+
+

+ {data.totalCount.toLocaleString()} matching pull requests +

+ + Page {data.page} + {enriching ? " · Loading insights…" : ""} + +
+
+ {[ + ...data.notices, + ...(enrichment?.notices ?? []), + ...(enrichmentError ? [enrichmentError] : []), + ].map((notice) => ( +

{notice}

+ ))} +
+ {!data.pullRequests.length && ( +

+ No matching pull requests. Try another technology, repository, or + status. +

+ )} +
+ {data.pullRequests.map((pr) => ( + + ))} +
+
+ + +
+
+ )} + + ); +} + +export function PullRequestDashboard() { + const params = useSearchParams(); + return ( +
+
+
+
+ + + OSS Pull Request Finder + +
+ + +
+
+ +
+

+ Find pull requests by org and tech. +

+

+ Explore open-source changes with repository health, maintainer + responsiveness, reviews, and contribution guides. +

+
+
+
+
+ +
+
+ ); +} diff --git a/src/features/pull-requests/data/README.md b/src/features/pull-requests/data/README.md new file mode 100644 index 0000000..2dffc19 --- /dev/null +++ b/src/features/pull-requests/data/README.md @@ -0,0 +1,13 @@ +# GitHub language names + +`languages.json` is a snapshot of canonical names and aliases from +[GitHub Linguist's languages.yml](https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml), +retrieved September 13, 2026 (835 languages, 1,269 names and aliases). + +Keys are lowercase names/aliases; values are canonical GitHub language names. +Canonical names take precedence over aliases. Existing framework mappings remain +topic searches. The bundled lookup adds no dependency or runtime network request. + +To refresh, parse the upstream YAML, map each alias to its language, then map all +canonical names and sort the resulting object by key. GitHub Linguist is +[MIT licensed](https://github.com/github-linguist/linguist/blob/main/LICENSE). diff --git a/src/features/pull-requests/data/languages.json b/src/features/pull-requests/data/languages.json new file mode 100644 index 0000000..3076ae8 --- /dev/null +++ b/src/features/pull-requests/data/languages.json @@ -0,0 +1,1271 @@ +{ + "1c enterprise": "1C Enterprise", + "2-dimensional array": "2-Dimensional Array", + "4d": "4D", + "abap": "ABAP", + "abap cds": "ABAP CDS", + "abl": "OpenEdge ABL", + "abnf": "ABNF", + "abuild": "Alpine Abuild", + "acfm": "Adobe Font Metrics", + "ackrc": "Option List", + "aconf": "ApacheConf", + "actionscript": "ActionScript", + "actionscript 3": "ActionScript", + "actionscript3": "ActionScript", + "ad block": "Adblock Filter List", + "ad block filters": "Adblock Filter List", + "ada": "Ada", + "ada2005": "Ada", + "ada95": "Ada", + "adb": "Adblock Filter List", + "adblock": "Adblock Filter List", + "adblock filter list": "Adblock Filter List", + "adobe composite font metrics": "Adobe Font Metrics", + "adobe font metrics": "Adobe Font Metrics", + "adobe multiple font metrics": "Adobe Font Metrics", + "advpl": "xBase", + "afdko": "OpenType Feature File", + "agda": "Agda", + "ags": "AGS Script", + "ags script": "AGS Script", + "ahk": "AutoHotkey", + "aidl": "AIDL", + "aiken": "Aiken", + "al": "AL", + "aleo": "Aleo", + "algol": "ALGOL", + "alloy": "Alloy", + "alpine abuild": "Alpine Abuild", + "altium": "Altium Designer", + "altium designer": "Altium Designer", + "amfm": "Adobe Font Metrics", + "ampl": "AMPL", + "amusewiki": "Muse", + "angelscript": "AngelScript", + "answer set programming": "Answer Set Programming", + "ant build system": "Ant Build System", + "antlers": "Antlers", + "antlr": "ANTLR", + "apache": "ApacheConf", + "apacheconf": "ApacheConf", + "apex": "Apex", + "api blueprint": "API Blueprint", + "apkbuild": "Alpine Abuild", + "apl": "APL", + "apollo guidance computer": "Apollo Guidance Computer", + "apples": "AppleScript", + "applescript": "AppleScript", + "arc": "Arc", + "arexx": "REXX", + "arkts": "ArkTS", + "as3": "ActionScript", + "ascii stl": "STL", + "asciidoc": "AsciiDoc", + "asl": "ASL", + "asm": "Assembly", + "asn.1": "ASN.1", + "asp": "Classic ASP", + "asp.net": "ASP.NET", + "aspectj": "AspectJ", + "aspx": "ASP.NET", + "aspx-vb": "ASP.NET", + "assembly": "Assembly", + "astro": "Astro", + "asymptote": "Asymptote", + "ats": "ATS", + "ats2": "ATS", + "au3": "AutoIt", + "augeas": "Augeas", + "autoconf": "M4Sugar", + "autohotkey": "AutoHotkey", + "autoit": "AutoIt", + "autoit3": "AutoIt", + "autoitscript": "AutoIt", + "avro idl": "Avro IDL", + "awk": "Awk", + "b": "B", + "b (formal method)": "B (Formal Method)", + "b3d": "BlitzBasic", + "b4x": "B4X", + "ballerina": "Ballerina", + "baml": "BAML", + "bash": "Shell", + "bash session": "ShellSession", + "basic": "BASIC", + "basic for android": "B4X", + "bat": "Batchfile", + "batch": "Batchfile", + "batchfile": "Batchfile", + "bazel": "Starlark", + "bbcode": "BBCode", + "be": "Berry", + "beef": "Beef", + "befunge": "Befunge", + "berry": "Berry", + "bh": "Bluespec BH", + "bibtex": "BibTeX", + "bibtex style": "BibTeX Style", + "bicep": "Bicep", + "bikeshed": "Bikeshed", + "bird": "BIRD2", + "bird2": "BIRD2", + "bird3": "BIRD2", + "bison": "Bison", + "bitbake": "BitBake", + "blade": "Blade", + "blitz3d": "BlitzBasic", + "blitzbasic": "BlitzBasic", + "blitzmax": "BlitzMax", + "blitzplus": "BlitzBasic", + "blp": "Blueprint", + "blueprint": "Blueprint", + "bluespec": "Bluespec", + "bluespec bh": "Bluespec BH", + "bluespec bsv": "Bluespec", + "bluespec classic": "Bluespec BH", + "bmax": "BlitzMax", + "boo": "Boo", + "boogie": "Boogie", + "bplus": "BlitzBasic", + "bqn": "BQN", + "brainfuck": "Brainfuck", + "brighterscript": "BrighterScript", + "brightscript": "Brightscript", + "bro": "Zeek", + "browserslist": "Browserslist", + "bru": "Bru", + "bsdmake": "Makefile", + "bsv": "Bluespec", + "buildstream": "BuildStream", + "byond": "DM", + "bzl": "Starlark", + "c": "C", + "c-objdump": "C-ObjDump", + "c#": "C#", + "c++": "C++", + "c++-objdump": "Cpp-ObjDump", + "c2hs": "C2hs Haskell", + "c2hs haskell": "C2hs Haskell", + "c3": "C3", + "cabal": "Cabal Config", + "cabal config": "Cabal Config", + "caddy": "Caddyfile", + "caddyfile": "Caddyfile", + "cadence": "Cadence", + "cairo": "Cairo", + "cairo zero": "Cairo Zero", + "cake": "C#", + "cakescript": "C#", + "cameligo": "CameLIGO", + "cangjie": "Cangjie", + "cap cds": "CAP CDS", + "cap'n proto": "Cap'n Proto", + "carbon": "Carbon", + "carto": "CartoCSS", + "cartocss": "CartoCSS", + "cask": "Emacs Lisp", + "cds": "CAP CDS", + "ceylon": "Ceylon", + "cfc": "ColdFusion CFC", + "cfm": "ColdFusion", + "cfml": "ColdFusion", + "chapel": "Chapel", + "charity": "Charity", + "checksum": "Checksums", + "checksums": "Checksums", + "chpl": "Chapel", + "chuck": "ChucK", + "cil": "CIL", + "circom": "Circom", + "cirru": "Cirru", + "clarion": "Clarion", + "clarity": "Clarity", + "classic asp": "Classic ASP", + "classic qbasic": "QuickBASIC", + "classic quickbasic": "QuickBASIC", + "classic visual basic": "Visual Basic 6.0", + "clean": "Clean", + "click": "Click", + "clipper": "xBase", + "clips": "CLIPS", + "clojure": "Clojure", + "closure templates": "Closure Templates", + "cloud firestore security rules": "Cloud Firestore Security Rules", + "clue": "Clue", + "cmake": "CMake", + "cobol": "COBOL", + "coccinelle": "SmPL", + "codeowners": "CODEOWNERS", + "codeql": "CodeQL", + "coffee": "CoffeeScript", + "coffee-script": "CoffeeScript", + "coffeescript": "CoffeeScript", + "coldfusion": "ColdFusion", + "coldfusion cfc": "ColdFusion CFC", + "coldfusion html": "ColdFusion", + "collada": "COLLADA", + "commit": "Git Commit", + "common lisp": "Common Lisp", + "common workflow language": "Common Workflow Language", + "component pascal": "Component Pascal", + "conf": "INI", + "conll": "CoNLL-U", + "conll-u": "CoNLL-U", + "conll-x": "CoNLL-U", + "console": "ShellSession", + "containerfile": "Dockerfile", + "cooklang": "Cooklang", + "cool": "Cool", + "coq": "Rocq Prover", + "cperl": "Perl", + "cpp": "C++", + "cpp-objdump": "Cpp-ObjDump", + "cql": "CQL", + "creole": "Creole", + "cron": "crontab", + "cron table": "crontab", + "crontab": "crontab", + "crystal": "Crystal", + "csharp": "C#", + "cson": "CSON", + "csound": "Csound", + "csound document": "Csound Document", + "csound score": "Csound Score", + "csound-csd": "Csound Document", + "csound-orc": "Csound", + "csound-sco": "Csound Score", + "css": "CSS", + "csv": "CSV", + "cucumber": "Gherkin", + "cuda": "Cuda", + "cue": "CUE", + "cue sheet": "Cue Sheet", + "curl config": "cURL Config", + "curlrc": "cURL Config", + "curry": "Curry", + "cweb": "CWeb", + "cwl": "Common Workflow Language", + "cycript": "Cycript", + "cylc": "Cylc", + "cypher": "Cypher", + "cython": "Cython", + "d": "D", + "d-objdump": "D-ObjDump", + "d2": "D2", + "d2lang": "D2", + "dafny": "Dafny", + "darcs patch": "Darcs Patch", + "dart": "Dart", + "daslang": "Daslang", + "dataweave": "DataWeave", + "dcl": "DIGITAL Command Language", + "debian package control file": "Debian Package Control File", + "delphi": "Pascal", + "denizenscript": "DenizenScript", + "desktop": "desktop", + "dhall": "Dhall", + "diff": "Diff", + "digital command language": "DIGITAL Command Language", + "dircolors": "dircolors", + "directx 3d file": "DirectX 3D File", + "django": "Jinja", + "dlang": "D", + "dm": "DM", + "dns zone": "DNS Zone", + "dockerfile": "Dockerfile", + "dogescript": "Dogescript", + "dosbatch": "Batchfile", + "dosini": "INI", + "dotenv": "Dotenv", + "dpatch": "Darcs Patch", + "dtrace": "DTrace", + "dtrace-script": "DTrace", + "dune": "Dune", + "dylan": "Dylan", + "e": "E", + "e-mail": "E-mail", + "eagle": "Eagle", + "earthfile": "Earthly", + "earthly": "Earthly", + "eask": "Emacs Lisp", + "easybuild": "Easybuild", + "ebnf": "EBNF", + "ec": "eC", + "ecere projects": "Ecere Projects", + "ecl": "ECL", + "eclipse": "ECLiPSe", + "ecmarkdown": "Ecmarkup", + "ecmarkup": "Ecmarkup", + "ecr": "HTML+ECR", + "edge": "Edge", + "edgeql": "EdgeQL", + "editor-config": "EditorConfig", + "editorconfig": "EditorConfig", + "edje data collection": "Edje Data Collection", + "edn": "edn", + "eeschema schematic": "KiCad Schematic", + "eex": "HTML+EEX", + "eiffel": "Eiffel", + "ejs": "EJS", + "electronic business card": "vCard", + "elisp": "Emacs Lisp", + "elixir": "Elixir", + "elm": "Elm", + "elvish": "Elvish", + "elvish transcript": "Elvish Transcript", + "emacs": "Emacs Lisp", + "emacs lisp": "Emacs Lisp", + "emacs muse": "Muse", + "email": "E-mail", + "emberscript": "EmberScript", + "eml": "E-mail", + "envrc": "Shell", + "eq": "EQ", + "erb": "HTML+ERB", + "erlang": "Erlang", + "esdl": "EdgeQL", + "euphoria": "Euphoria", + "f*": "F*", + "f#": "F#", + "factor": "Factor", + "fancy": "Fancy", + "fantom": "Fantom", + "faust": "Faust", + "fb": "FreeBASIC", + "fennel": "Fennel", + "figfont": "FIGlet Font", + "figlet font": "FIGlet Font", + "filebench wml": "Filebench WML", + "filterscript": "Filterscript", + "firrtl": "FIRRTL", + "fish": "fish", + "flatbuffers": "FlatBuffers", + "flex": "Lex", + "flix": "Flix", + "fluent": "Fluent", + "flux": "FLUX", + "formatted": "Formatted", + "forth": "Forth", + "fortran": "Fortran", + "fortran free form": "Fortran Free Form", + "foxpro": "xBase", + "fpp": "FPP", + "freebasic": "FreeBASIC", + "freemarker": "FreeMarker", + "frege": "Frege", + "fsharp": "F#", + "fstar": "F*", + "ftl": "FreeMarker", + "fundamental": "Text", + "futhark": "Futhark", + "g-code": "G-code", + "game maker language": "Game Maker Language", + "gaml": "GAML", + "gams": "GAMS", + "gap": "GAP", + "gas": "Unix Assembly", + "gcc machine description": "GCC Machine Description", + "gdb": "GDB", + "gdscript": "GDScript", + "gdshader": "GDShader", + "gedcom": "GEDCOM", + "gemfile.lock": "Gemfile.lock", + "gemini": "Gemini", + "gemtext": "Gemini", + "genero 4gl": "Genero 4gl", + "genero per": "Genero per", + "genie": "Genie", + "genshi": "Genshi", + "gentoo ebuild": "Gentoo Ebuild", + "gentoo eclass": "Gentoo Eclass", + "geojson": "JSON", + "gerber image": "Gerber Image", + "gettext catalog": "Gettext Catalog", + "gf": "Grammatical Framework", + "gherkin": "Gherkin", + "git attributes": "Git Attributes", + "git blame ignore revs": "Git Revision List", + "git commit": "Git Commit", + "git config": "Git Config", + "git revision list": "Git Revision List", + "git-ignore": "Ignore List", + "gitattributes": "Git Attributes", + "gitconfig": "Git Config", + "gitignore": "Ignore List", + "gitmodules": "Git Config", + "gjs": "Glimmer JS", + "gleam": "Gleam", + "glimmer js": "Glimmer JS", + "glimmer ts": "Glimmer TS", + "glsl": "GLSL", + "glyph": "Glyph", + "glyph bitmap distribution format": "Glyph Bitmap Distribution Format", + "gn": "GN", + "gno": "Gno", + "gnolang": "Gno", + "gnu asm": "Unix Assembly", + "gnuplot": "Gnuplot", + "go": "Go", + "go checksums": "Go Checksums", + "go mod": "Go Module", + "go module": "Go Module", + "go sum": "Go Checksums", + "go template": "Go Template", + "go work": "Go Workspace", + "go work sum": "Go Checksums", + "go workspace": "Go Workspace", + "go.mod": "Go Module", + "go.sum": "Go Checksums", + "go.work": "Go Workspace", + "go.work.sum": "Go Checksums", + "godot resource": "Godot Resource", + "golang": "Go", + "golo": "Golo", + "gosu": "Gosu", + "gotmpl": "Go Template", + "grace": "Grace", + "gradle": "Gradle", + "gradle kotlin dsl": "Gradle Kotlin DSL", + "grammatical framework": "Grammatical Framework", + "graph modeling language": "Graph Modeling Language", + "graphql": "GraphQL", + "graphviz (dot)": "Graphviz (DOT)", + "groff": "Roff", + "groovy": "Groovy", + "groovy server pages": "Groovy Server Pages", + "gsc": "GSC", + "gsp": "Groovy Server Pages", + "gtk": "GtkRC", + "gtk 1": "GtkRC", + "gtk 2": "GtkRC", + "gtkrc": "GtkRC", + "gts": "Glimmer TS", + "hack": "Hack", + "haml": "Haml", + "handlebars": "Handlebars", + "haproxy": "HAProxy", + "harbour": "Harbour", + "hare": "Hare", + "hash": "Checksums", + "hashes": "Checksums", + "hashicorp configuration language": "HCL", + "haskell": "Haskell", + "haxe": "Haxe", + "hbs": "Handlebars", + "hcl": "HCL", + "heex": "HTML+EEX", + "help": "Vim Help File", + "hip": "HIP", + "hiveql": "HiveQL", + "hls playlist": "M3U", + "hlsl": "HLSL", + "hocon": "HOCON", + "holyc": "HolyC", + "hoon": "hoon", + "hosts": "Hosts File", + "hosts file": "Hosts File", + "html": "HTML", + "html+django": "Jinja", + "html+ecr": "HTML+ECR", + "html+eex": "HTML+EEX", + "html+erb": "HTML+ERB", + "html+jinja": "Jinja", + "html+php": "HTML+PHP", + "html+razor": "HTML+Razor", + "html+ruby": "HTML+ERB", + "htmlbars": "Handlebars", + "htmldjango": "Jinja", + "http": "HTTP", + "hurl": "Hurl", + "hxml": "HXML", + "hy": "Hy", + "hylang": "Hy", + "hyphy": "HyPhy", + "i7": "Inform 7", + "ical": "iCalendar", + "icalendar": "iCalendar", + "idl": "IDL", + "idris": "Idris", + "ignore": "Ignore List", + "ignore list": "Ignore List", + "igor": "IGOR Pro", + "igor pro": "IGOR Pro", + "igorpro": "IGOR Pro", + "ijm": "ImageJ Macro", + "il assembly": "IL Assembly", + "ilasm": "IL Assembly", + "ile rpg": "RPGLE", + "imagej macro": "ImageJ Macro", + "imba": "Imba", + "imhex": "ImHex Pattern Language", + "imhex pattern language": "ImHex Pattern Language", + "imhexpatternlanguage": "ImHex Pattern Language", + "imhexpl": "ImHex Pattern Language", + "inc": "PHP", + "inform 7": "Inform 7", + "inform7": "Inform 7", + "ini": "INI", + "ink": "Ink", + "inno setup": "Inno Setup", + "inputrc": "Readline Config", + "io": "Io", + "ioke": "Ioke", + "ipython notebook": "Jupyter Notebook", + "irc": "IRC log", + "irc log": "IRC log", + "irc logs": "IRC log", + "isabelle": "Isabelle", + "isabelle root": "Isabelle ROOT", + "ispc": "ISPC", + "j": "J", + "jac": "Jac", + "jai": "Jai", + "janet": "Janet", + "jar manifest": "JAR Manifest", + "jasmin": "Jasmin", + "jass": "JASS", + "jass2": "JASS", + "java": "Java", + "java properties": "Java Properties", + "java server page": "Groovy Server Pages", + "java server pages": "Java Server Pages", + "java template engine": "Java Template Engine", + "javascript": "JavaScript", + "javascript+erb": "JavaScript+ERB", + "jcl": "JCL", + "jest snapshot": "Jest Snapshot", + "jetbrains mps": "JetBrains MPS", + "jflex": "JFlex", + "jinja": "Jinja", + "jison": "Jison", + "jison lex": "Jison Lex", + "jolie": "Jolie", + "jq": "jq", + "jruby": "Ruby", + "js": "JavaScript", + "json": "JSON", + "json with comments": "JSON with Comments", + "json5": "JSON5", + "jsonc": "JSON with Comments", + "jsoniq": "JSONiq", + "jsonl": "JSON", + "jsonld": "JSONLD", + "jsonnet": "Jsonnet", + "jsp": "Java Server Pages", + "jte": "Java Template Engine", + "julia": "Julia", + "julia repl": "Julia REPL", + "jupyter notebook": "Jupyter Notebook", + "just": "Just", + "justfile": "Just", + "kaitai struct": "Kaitai Struct", + "kak": "KakouneScript", + "kakounescript": "KakouneScript", + "kakscript": "KakouneScript", + "kcl": "KCL", + "kdl": "KDL", + "kerboscript": "KerboScript", + "keyvalues": "Valve Data Format", + "kframework": "KFramework", + "kicad layout": "KiCad Layout", + "kicad legacy layout": "KiCad Legacy Layout", + "kicad schematic": "KiCad Schematic", + "kickstart": "Kickstart", + "kit": "Kit", + "koka": "Koka", + "kolmafia ash": "KoLmafia ASH", + "kotlin": "Kotlin", + "krl": "KRL", + "ksy": "Kaitai Struct", + "kusto": "Kusto", + "kvlang": "kvlang", + "labview": "LabVIEW", + "lambdapi": "Lambdapi", + "langium": "Langium", + "lark": "Lark", + "lasso": "Lasso", + "lassoscript": "Lasso", + "latex": "TeX", + "latte": "Latte", + "lean": "Lean", + "lean 4": "Lean 4", + "lean4": "Lean 4", + "leex": "HTML+EEX", + "leo": "Leo", + "less": "Less", + "less-css": "Less", + "lex": "Lex", + "lfe": "LFE", + "lhaskell": "Literate Haskell", + "lhs": "Literate Haskell", + "ligolang": "LigoLANG", + "lilypond": "LilyPond", + "limbo": "Limbo", + "linear programming": "Linear Programming", + "linker script": "Linker Script", + "linux kernel module": "Linux Kernel Module", + "liquid": "Liquid", + "liquidsoap": "Liquidsoap", + "lisp": "Common Lisp", + "litcoffee": "Literate CoffeeScript", + "literate agda": "Literate Agda", + "literate coffeescript": "Literate CoffeeScript", + "literate haskell": "Literate Haskell", + "live-script": "LiveScript", + "livecode script": "LiveCode Script", + "livescript": "LiveScript", + "llvm": "LLVM", + "llvm tablegen": "LLVM TableGen", + "lobster": "Lobster", + "logos": "Logos", + "logtalk": "Logtalk", + "lolcode": "LOLCODE", + "lookml": "LookML", + "loomscript": "LoomScript", + "ls": "LiveScript", + "lsl": "LSL", + "ltspice symbol": "LTspice Symbol", + "lua": "Lua", + "luau": "Luau", + "m": "M", + "m2": "Macaulay2", + "m3u": "M3U", + "m3u playlist": "M3U", + "m4": "M4", + "m4sugar": "M4Sugar", + "m68k": "Motorola 68K Assembly", + "macaulay2": "Macaulay2", + "macruby": "Ruby", + "mail": "E-mail", + "make": "Makefile", + "makefile": "Makefile", + "mako": "Mako", + "man": "Roff", + "man page": "Roff", + "man-page": "Roff", + "manpage": "Roff", + "markdown": "Markdown", + "marko": "Marko", + "markojs": "Marko", + "mask": "Mask", + "mathematica": "Wolfram Language", + "mathematical programming system": "Mathematical Programming System", + "matlab": "MATLAB", + "maven pom": "Maven POM", + "max": "Max", + "max/msp": "Max", + "maxmsp": "Max", + "maxscript": "MAXScript", + "mbox": "E-mail", + "mcfunction": "mcfunction", + "md": "Markdown", + "mdoc": "Roff", + "mdsvex": "mdsvex", + "mdx": "MDX", + "mediawiki": "Wikitext", + "mercury": "Mercury", + "mermaid": "Mermaid", + "mermaid example": "Mermaid", + "meson": "Meson", + "metal": "Metal", + "metta": "MeTTa", + "mf": "Makefile", + "microsoft developer studio project": "Microsoft Developer Studio Project", + "microsoft visual studio solution": "Microsoft Visual Studio Solution", + "minid": "MiniD", + "miniscript": "MiniScript", + "miniyaml": "MiniYAML", + "minizinc": "MiniZinc", + "minizinc data": "MiniZinc Data", + "mint": "Mint", + "mirah": "Mirah", + "mirc script": "mIRC Script", + "mlir": "MLIR", + "mma": "Wolfram Language", + "modelica": "Modelica", + "modula-2": "Modula-2", + "modula-3": "Modula-3", + "module management system": "Module Management System", + "mojo": "Mojo", + "monkey": "Monkey", + "monkey c": "Monkey C", + "moocode": "Moocode", + "moonbit": "MoonBit", + "moonscript": "MoonScript", + "motoko": "Motoko", + "motorola 68k assembly": "Motorola 68K Assembly", + "move": "Move", + "mps": "JetBrains MPS", + "mql4": "MQL4", + "mql5": "MQL5", + "msil": "IL Assembly", + "mtml": "MTML", + "muf": "MUF", + "mumps": "M", + "mupad": "mupad", + "muse": "Muse", + "mustache": "Mustache", + "myghty": "Myghty", + "nanorc": "nanorc", + "nargo": "Noir", + "nasal": "Nasal", + "nasl": "NASL", + "nasm": "Assembly", + "ncl": "NCL", + "ne-on": "NEON", + "nearley": "Nearley", + "nemerle": "Nemerle", + "neon": "NEON", + "neosnippet": "Vim Snippet", + "nesc": "nesC", + "netlinx": "NetLinx", + "netlinx+erb": "NetLinx+ERB", + "netlogo": "NetLogo", + "nette object notation": "NEON", + "newlisp": "NewLisp", + "nextflow": "Nextflow", + "nginx": "Nginx", + "nginx configuration file": "Nginx", + "nickel": "Nickel", + "nim": "Nim", + "ninja": "Ninja", + "nit": "Nit", + "nix": "Nix", + "nixos": "Nix", + "njk": "Nunjucks", + "nl": "NL", + "nmodl": "NMODL", + "node": "JavaScript", + "noir": "Noir", + "npm config": "NPM Config", + "npmrc": "NPM Config", + "nroff": "Roff", + "nsis": "NSIS", + "nu": "Nu", + "nu-script": "Nushell", + "numpy": "NumPy", + "nunjucks": "Nunjucks", + "nush": "Nu", + "nushell": "Nushell", + "nushell-script": "Nushell", + "nvim": "Vim script", + "nwscript": "NWScript", + "oasv2": "OpenAPI Specification v2", + "oasv2-json": "OASv2-json", + "oasv2-yaml": "OASv2-yaml", + "oasv3": "OpenAPI Specification v3", + "oasv3-json": "OASv3-json", + "oasv3-yaml": "OASv3-yaml", + "oberon": "Oberon", + "obj-c": "Objective-C", + "obj-c++": "Objective-C++", + "obj-j": "Objective-J", + "objc": "Objective-C", + "objc++": "Objective-C++", + "objdump": "ObjDump", + "object data instance notation": "Object Data Instance Notation", + "objective-c": "Objective-C", + "objective-c++": "Objective-C++", + "objective-j": "Objective-J", + "objectivec": "Objective-C", + "objectivec++": "Objective-C++", + "objectivej": "Objective-J", + "objectpascal": "Pascal", + "objectscript": "ObjectScript", + "objj": "Objective-J", + "ocaml": "OCaml", + "octave": "MATLAB", + "odin": "Odin", + "odin-lang": "Odin", + "odinlang": "Odin", + "omgrofl": "Omgrofl", + "omnet++ msg": "OMNeT++ MSG", + "omnet++ ned": "OMNeT++ NED", + "omnetpp-msg": "OMNeT++ MSG", + "omnetpp-ned": "OMNeT++ NED", + "oncrpc": "RPC", + "ooc": "ooc", + "opa": "Opa", + "opal": "Opal", + "open policy agent": "Open Policy Agent", + "openapi specification v2": "OpenAPI Specification v2", + "openapi specification v3": "OpenAPI Specification v3", + "opencl": "OpenCL", + "openedge": "OpenEdge ABL", + "openedge abl": "OpenEdge ABL", + "openqasm": "OpenQASM", + "openrc": "OpenRC runscript", + "openrc runscript": "OpenRC runscript", + "openscad": "OpenSCAD", + "openstep property list": "OpenStep Property List", + "opentofu": "HCL", + "opentype feature file": "OpenType Feature File", + "option list": "Option List", + "opts": "Option List", + "opy": "OverPy", + "org": "Org", + "osascript": "AppleScript", + "overpassql": "OverpassQL", + "overpy": "OverPy", + "ox": "Ox", + "oxygene": "Oxygene", + "oz": "Oz", + "p4": "P4", + "pact": "Pact", + "pan": "Pan", + "pandoc": "Markdown", + "papyrus": "Papyrus", + "parrot": "Parrot", + "parrot assembly": "Parrot Assembly", + "parrot internal representation": "Parrot Internal Representation", + "pascal": "Pascal", + "pasm": "Parrot Assembly", + "pawn": "Pawn", + "pcbnew": "KiCad Layout", + "pddl": "PDDL", + "peg.js": "PEG.js", + "pep8": "Pep8", + "perl": "Perl", + "perl-6": "Raku", + "perl6": "Raku", + "php": "PHP", + "pic": "Pic", + "pickle": "Pickle", + "picolisp": "PicoLisp", + "piglatin": "PigLatin", + "pikchr": "Pic", + "pike": "Pike", + "pip requirements": "Pip Requirements", + "pir": "Parrot Internal Representation", + "pkg-config": "pkg-config", + "pkgconf": "pkg-config", + "pkl": "Pkl", + "plain text": "Text", + "plantuml": "PlantUML", + "plpgsql": "PLpgSQL", + "plsql": "PLSQL", + "pod": "Pod", + "pod 6": "Pod 6", + "pogoscript": "PogoScript", + "polar": "Polar", + "pony": "Pony", + "portugol": "Portugol", + "posh": "PowerShell", + "postcss": "PostCSS", + "postscr": "PostScript", + "postscript": "PostScript", + "pot": "Gettext Catalog", + "pov-ray": "POV-Ray SDL", + "pov-ray sdl": "POV-Ray SDL", + "povray": "POV-Ray SDL", + "power query": "Power Query", + "powerbuilder": "PowerBuilder", + "powerquery": "Power Query", + "powershell": "PowerShell", + "praat": "Praat", + "prisma": "Prisma", + "pro*c": "Pro*C", + "processing": "Processing", + "procfile": "Procfile", + "progress": "OpenEdge ABL", + "proguard": "Proguard", + "prolog": "Prolog", + "promela": "Promela", + "propeller spin": "Propeller Spin", + "proto": "Protocol Buffer", + "protobuf": "Protocol Buffer", + "protobuf text format": "Protocol Buffer Text Format", + "protocol buffer": "Protocol Buffer", + "protocol buffer text format": "Protocol Buffer Text Format", + "protocol buffers": "Protocol Buffer", + "public key": "Public Key", + "pug": "Pug", + "puppet": "Puppet", + "pure data": "Pure Data", + "purebasic": "PureBasic", + "purescript": "PureScript", + "pwsh": "PowerShell", + "py": "Python", + "py3": "Python", + "pycon": "Python console", + "pyret": "Pyret", + "pyrex": "Cython", + "python": "Python", + "python console": "Python console", + "python traceback": "Python traceback", + "python3": "Python", + "q": "q", + "q#": "Q#", + "qb": "QuickBASIC", + "qb64": "QuickBASIC", + "qbasic": "QuickBASIC", + "ql": "CodeQL", + "qmake": "QMake", + "qml": "QML", + "qsharp": "Q#", + "qt script": "Qt Script", + "quake": "Quake", + "quakec": "QuakeC", + "quartus simulation ip": "Quartus Simulation IP", + "quickbasic": "QuickBASIC", + "quint": "Quint", + "r": "R", + "racket": "Racket", + "ragel": "Ragel", + "ragel-rb": "Ragel", + "ragel-ruby": "Ragel", + "rake": "Ruby", + "raku": "Raku", + "raml": "RAML", + "rascal": "Rascal", + "rascript": "RAScript", + "raw": "Raw token data", + "raw token data": "Raw token data", + "razor": "HTML+Razor", + "rb": "Ruby", + "rbs": "RBS", + "rbx": "Ruby", + "rdoc": "RDoc", + "readline": "Readline Config", + "readline config": "Readline Config", + "realbasic": "REALbasic", + "reason": "Reason", + "reasonligo": "ReasonLIGO", + "rebol": "Rebol", + "record jar": "Record Jar", + "red": "Red", + "red/system": "Red", + "redcode": "Redcode", + "redirect rules": "Redirect Rules", + "redirects": "Redirect Rules", + "redscript": "Redscript", + "regex": "Regular Expression", + "regexp": "Regular Expression", + "regular expression": "Regular Expression", + "ren'py": "Ren'Py", + "renderscript": "RenderScript", + "renpy": "Ren'Py", + "rescript": "ReScript", + "restructuredtext": "reStructuredText", + "rexx": "REXX", + "rez": "Rez", + "rhai": "Rhai", + "rhtml": "HTML+ERB", + "rich text format": "Rich Text Format", + "ring": "Ring", + "riot": "Riot", + "rmarkdown": "RMarkdown", + "robotframework": "RobotFramework", + "robots": "Robots Exclusion Rules", + "robots exclusion rules": "Robots Exclusion Rules", + "robots txt": "Robots Exclusion Rules", + "robotx.txt": "Robots Exclusion Rules", + "roc": "Roc", + "rocq": "Rocq Prover", + "rocq prover": "Rocq Prover", + "roff": "Roff", + "roff manpage": "Roff Manpage", + "ron": "RON", + "ros interface": "ROS Interface", + "rosmsg": "ROS Interface", + "rouge": "Rouge", + "routeros script": "RouterOS Script", + "rpc": "RPC", + "rpcgen": "RPC", + "rpgle": "RPGLE", + "rpm spec": "RPM Spec", + "rs": "Rust", + "rs-274x": "Gerber Image", + "rscript": "R", + "rss": "XML", + "rst": "reStructuredText", + "ruby": "Ruby", + "runoff": "RUNOFF", + "rust": "Rust", + "rusthon": "Python", + "sage": "Sage", + "sail": "Sail", + "salt": "Salt", + "saltstack": "Salt", + "saltstate": "Salt", + "sarif": "JSON", + "sas": "SAS", + "sass": "Sass", + "scala": "Scala", + "scaml": "Scaml", + "scenic": "Scenic", + "scheme": "Scheme", + "scilab": "Scilab", + "scss": "SCSS", + "sdc": "Tcl", + "sed": "sed", + "self": "Self", + "selinux kernel policy language": "SELinux Policy", + "selinux policy": "SELinux Policy", + "sepolicy": "SELinux Policy", + "sfv": "Simple File Verification", + "sh": "Shell", + "shaderlab": "ShaderLab", + "shell": "Shell", + "shell-script": "Shell", + "shellcheck config": "ShellCheck Config", + "shellcheckrc": "ShellCheck Config", + "shellsession": "ShellSession", + "shen": "Shen", + "sieve": "Sieve", + "simple file verification": "Simple File Verification", + "singularity": "Singularity", + "sip": "SIP", + "slang": "Slang", + "slash": "Slash", + "slice": "Slice", + "slim": "Slim", + "slint": "Slint", + "smali": "Smali", + "smalltalk": "Smalltalk", + "smarty": "Smarty", + "smithy": "Smithy", + "sml": "Standard ML", + "smpl": "SmPL", + "smt": "SMT", + "snakefile": "Snakemake", + "snakemake": "Snakemake", + "snipmate": "Vim Snippet", + "snippet": "YASnippet", + "solidity": "Solidity", + "soong": "Soong", + "sourcemod": "SourcePawn", + "sourcepawn": "SourcePawn", + "soy": "Closure Templates", + "sparql": "SPARQL", + "specfile": "RPM Spec", + "spicedb schema": "SpiceDB Schema", + "spline font database": "Spline Font Database", + "splus": "R", + "sqf": "SQF", + "sql": "SQL", + "sqlpl": "SQLPL", + "sqlrpgle": "RPGLE", + "squeak": "Smalltalk", + "squirrel": "Squirrel", + "srecode template": "SRecode Template", + "ssh config": "SSH Config", + "ssh_config": "SSH Config", + "sshconfig": "SSH Config", + "sshd_config": "SSH Config", + "sshdconfig": "SSH Config", + "stan": "Stan", + "standard ml": "Standard ML", + "star": "STAR", + "starlark": "Starlark", + "stata": "Stata", + "stl": "STL", + "stla": "STL", + "ston": "STON", + "stringtemplate": "StringTemplate", + "stylus": "Stylus", + "subrip text": "SubRip Text", + "sugarss": "SugarSS", + "sum": "Checksums", + "sums": "Checksums", + "supercollider": "SuperCollider", + "surql": "SurrealQL", + "surrealql": "SurrealQL", + "survex data": "Survex data", + "svelte": "Svelte", + "svg": "SVG", + "sway": "Sway", + "sweave": "Sweave", + "swift": "Swift", + "swig": "SWIG", + "systemverilog": "SystemVerilog", + "tab-seperated values": "TSV", + "tablegen": "LLVM TableGen", + "tabular model definition language": "TMDL", + "tact": "Tact", + "talon": "Talon", + "tape": "Tape", + "tcl": "Tcl", + "tcsh": "Tcsh", + "tea": "Tea", + "teal": "Teal", + "templ": "templ", + "terra": "Terra", + "terraform": "HCL", + "terraform template": "Terraform Template", + "tex": "TeX", + "texinfo": "Texinfo", + "text": "Text", + "text proto": "Protocol Buffer Text Format", + "textgrid": "TextGrid", + "textile": "Textile", + "textmate properties": "TextMate Properties", + "thrift": "Thrift", + "ti program": "TI Program", + "tl": "Type Language", + "tl-verilog": "TL-Verilog", + "tla": "TLA", + "tm-properties": "TextMate Properties", + "tmdl": "TMDL", + "toit": "Toit", + "tolk": "Tolk", + "toml": "TOML", + "topojson": "JSON", + "tor config": "Tor Config", + "torrc": "Tor Config", + "traveling salesman problem": "TSPLIB data", + "travelling salesman problem": "TSPLIB data", + "tree-sitter query": "Tree-sitter Query", + "troff": "Roff", + "ts": "TypeScript", + "tsp": "TypeSpec", + "tsplib data": "TSPLIB data", + "tsq": "Tree-sitter Query", + "tsql": "TSQL", + "tsv": "TSV", + "tsx": "TSX", + "turing": "Turing", + "turtle": "Turtle", + "twig": "Twig", + "txl": "TXL", + "typ": "Typst", + "type language": "Type Language", + "typescript": "TypeScript", + "typescriptreact": "TSX", + "typespec": "TypeSpec", + "typst": "Typst", + "ucode": "ucode", + "udiff": "Diff", + "ultisnip": "Vim Snippet", + "ultisnips": "Vim Snippet", + "unified parallel c": "Unified Parallel C", + "unity3d asset": "Unity3D Asset", + "unix asm": "Unix Assembly", + "unix assembly": "Unix Assembly", + "uno": "Uno", + "unrealscript": "UnrealScript", + "untyped plutus core": "Untyped Plutus Core", + "ur": "UrWeb", + "ur/web": "UrWeb", + "urweb": "UrWeb", + "v": "V", + "vala": "Vala", + "valve data format": "Valve Data Format", + "vb .net": "Visual Basic .NET", + "vb 6": "Visual Basic 6.0", + "vb.net": "Visual Basic .NET", + "vb6": "Visual Basic 6.0", + "vba": "VBA", + "vbnet": "Visual Basic .NET", + "vbscript": "VBScript", + "vcard": "vCard", + "vcl": "VCL", + "vdf": "Valve Data Format", + "velocity": "Velocity Template Language", + "velocity template language": "Velocity Template Language", + "vento": "Vento", + "verilog": "Verilog", + "verse": "Verse", + "vespa": "Vespa Schema Definition", + "vespa schema definition": "Vespa Schema Definition", + "vhdl": "VHDL", + "vhs": "Tape", + "vim": "Vim script", + "vim help file": "Vim Help File", + "vim script": "Vim script", + "vim snippet": "Vim Snippet", + "vimhelp": "Vim Help File", + "viml": "Vim script", + "vimscript": "Vim script", + "virtual contact file": "vCard", + "visual basic": "Visual Basic .NET", + "visual basic .net": "Visual Basic .NET", + "visual basic 6": "Visual Basic 6.0", + "visual basic 6.0": "Visual Basic 6.0", + "visual basic classic": "Visual Basic 6.0", + "visual basic for applications": "VBA", + "vlang": "V", + "volt": "Volt", + "vtl": "Velocity Template Language", + "vtt": "WebVTT", + "vue": "Vue", + "vyper": "Vyper", + "wasm": "WebAssembly", + "wast": "WebAssembly", + "wavefront material": "Wavefront Material", + "wavefront object": "Wavefront Object", + "wdl": "WDL", + "web ontology language": "Web Ontology Language", + "webassembly": "WebAssembly", + "webassembly interface type": "WebAssembly Interface Type", + "webidl": "WebIDL", + "webvtt": "WebVTT", + "wget config": "Wget Config", + "wgetrc": "Wget Config", + "wgsl": "WGSL", + "whiley": "Whiley", + "wiki": "Wikitext", + "wikitext": "Wikitext", + "win32 message file": "Win32 Message File", + "winbatch": "Batchfile", + "windows registry entries": "Windows Registry Entries", + "wisp": "wisp", + "wit": "WebAssembly Interface Type", + "witcher script": "Witcher Script", + "wl": "Wolfram Language", + "wolfram": "Wolfram Language", + "wolfram lang": "Wolfram Language", + "wolfram language": "Wolfram Language", + "wollok": "Wollok", + "workflow description language": "WDL", + "world of warcraft addon data": "World of Warcraft Addon Data", + "wren": "Wren", + "wrenlang": "Wren", + "wsdl": "XML", + "x bitmap": "X BitMap", + "x font directory index": "X Font Directory Index", + "x pixmap": "X PixMap", + "x10": "X10", + "xbase": "xBase", + "xbm": "X BitMap", + "xc": "XC", + "xcompose": "XCompose", + "xdc": "Tcl", + "xdr": "RPC", + "xhtml": "HTML", + "xmake": "Xmake", + "xml": "XML", + "xml property list": "XML Property List", + "xml+genshi": "Genshi", + "xml+kid": "Genshi", + "xojo": "Xojo", + "xonsh": "Xonsh", + "xpages": "XPages", + "xpm": "X PixMap", + "xproc": "XProc", + "xquery": "XQuery", + "xs": "XS", + "xsd": "XML", + "xsl": "XSLT", + "xslt": "XSLT", + "xten": "X10", + "xtend": "Xtend", + "yacc": "Yacc", + "yaml": "YAML", + "yang": "YANG", + "yara": "YARA", + "yas": "YASnippet", + "yasnippet": "YASnippet", + "yml": "YAML", + "yul": "Yul", + "zap": "ZAP", + "zeek": "Zeek", + "zenscript": "ZenScript", + "zephir": "Zephir", + "zig": "Zig", + "zil": "ZIL", + "zimpl": "Zimpl", + "zmodel": "Zmodel", + "zsh": "Shell" +} diff --git a/src/features/pull-requests/filters.ts b/src/features/pull-requests/filters.ts new file mode 100644 index 0000000..3048d7c --- /dev/null +++ b/src/features/pull-requests/filters.ts @@ -0,0 +1,58 @@ +import { PR_SORTS, PR_STATUSES, type PullRequestFilters } from "./types"; + +export const DEFAULT_PR_FILTERS: PullRequestFilters = { + org: "", + tech: "", + repository: "", + status: "open", + sort: "updated", + page: 1, +}; +export const REPOSITORY_PATTERN = + /^[a-zA-Z0-9-]{1,39}\/(?!\.{1,2}$)[a-zA-Z0-9_.-]{1,100}$/; + +export function readPullRequestFilters( + params: Pick, +): PullRequestFilters { + return { + org: params.get("org")?.trim() ?? "", + tech: params.get("tech")?.trim() ?? "", + repository: params.get("repository")?.trim() ?? "", + status: (params.get("status") ?? "open") as PullRequestFilters["status"], + sort: (params.get("sort") ?? "updated") as PullRequestFilters["sort"], + page: Number(params.get("page") ?? 1), + }; +} + +export function validatePullRequestFilters( + filters: PullRequestFilters, +): string | null { + if (!/^[a-zA-Z0-9-]{1,39}$/.test(filters.org)) + return "Enter a valid GitHub organization."; + if ( + !filters.tech || + filters.tech.length > 80 || + !/^[\p{L}\p{N} #+._-]+$/u.test(filters.tech) + ) + return "Enter a language or technology (up to 80 characters)."; + if ( + filters.repository && + (!REPOSITORY_PATTERN.test(filters.repository) || + filters.repository.split("/")[0].toLowerCase() !== + filters.org.toLowerCase()) + ) + return "Repository must use organization/name and belong to the selected organization."; + if (!PR_STATUSES.includes(filters.status)) return "Choose a valid PR status."; + if (!PR_SORTS.includes(filters.sort)) return "Choose a valid sort order."; + if (!Number.isInteger(filters.page) || filters.page < 1 || filters.page > 10) + return "Page must be between 1 and 10."; + return null; +} + +export function pullRequestSearchParams(filters: PullRequestFilters) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(filters)) { + if (value !== "") params.set(key, String(value)); + } + return params; +} diff --git a/src/features/pull-requests/scoring.ts b/src/features/pull-requests/scoring.ts new file mode 100644 index 0000000..adb6910 --- /dev/null +++ b/src/features/pull-requests/scoring.ts @@ -0,0 +1,203 @@ +import type { + PullRequest, + PullRequestDetails, + PullRequestReview, +} from "./types"; + +export type ReadinessSignal = { + label: string; + points: number | null; + maximum: number; + reason: string; +}; + +export type PullRequestScore = { + minimum: number | null; + maximum: number | null; + coverage: number; + label: + | "Ready" + | "Needs attention" + | "Blocked" + | "Draft" + | "Partial" + | "Not applicable"; + signals: ReadinessSignal[]; + explanation: string; +}; + +const REVIEW_POINTS: Record = { + APPROVED: 30, + REVIEW_REQUIRED: 10, + CHANGES_REQUESTED: 0, +}; +const CHECK_POINTS: Record = { + SUCCESS: 25, + PENDING: 5, + EXPECTED: 5, + FAILURE: 0, + ERROR: 0, +}; + +function decisionSignal( + label: string, + value: string | null | undefined, + points: Record, + maximum: number, + unknownReason: string, +): ReadinessSignal { + if (!value || !Object.hasOwn(points, value)) + return { label, points: null, maximum, reason: unknownReason }; + return { + label, + points: points[value], + maximum, + reason: value.replaceAll("_", " ").toLowerCase(), + }; +} + +function conflictSignal( + mergeable: boolean | null | undefined, +): ReadinessSignal { + if (mergeable == null) + return { + label: "Merge conflicts", + points: null, + maximum: 15, + reason: "Mergeability unknown", + }; + return { + label: "Merge conflicts", + points: mergeable ? 15 : 0, + maximum: 15, + reason: mergeable + ? "No merge conflicts reported" + : "Merge conflicts reported", + }; +} + +function sizeSignal( + changes: PullRequestDetails | null | undefined, +): ReadinessSignal { + if ( + !changes || + ![changes.additions, changes.deletions, changes.changedFiles].every( + (value) => Number.isFinite(value) && value >= 0, + ) + ) { + return { + label: "Review size", + points: null, + maximum: 10, + reason: "Change size unknown", + }; + } + const lines = changes.additions + changes.deletions; + let points = 0; + if (lines <= 200 && changes.changedFiles <= 5) points = 10; + else if (lines <= 1000 && changes.changedFiles <= 20) points = 5; + return { + label: "Review size", + points, + maximum: 10, + reason: `${changes.changedFiles} files and ${lines} changed lines; smaller changes are easier to review`, + }; +} + +function readinessCap(status: PullRequest["status"], blocked: boolean) { + if (status === "draft") + return { + value: 39, + explanation: + "Draft readiness is capped at 39 until the author marks it ready for review.", + }; + if (blocked) + return { + value: 49, + explanation: + "Changes requested, failing checks, or merge conflicts cap readiness at 49.", + }; + return { + value: 100, + explanation: + "Readiness estimates review progress and effort; it does not judge code quality or guarantee a merge.", + }; +} + +function scoreLabel( + status: PullRequest["status"], + blocked: boolean, + unknown: number, + minimum: number, +): PullRequestScore["label"] { + if (status === "draft") return "Draft"; + if (blocked) return "Blocked"; + if (unknown > 0) return "Partial"; + return minimum >= 80 ? "Ready" : "Needs attention"; +} + +export function scorePullRequest( + status: PullRequest["status"], + review?: PullRequestReview | null, + details?: PullRequestDetails | null, +): PullRequestScore { + if (status === "closed" || status === "merged") { + return { + minimum: null, + maximum: null, + coverage: 0, + label: "Not applicable", + signals: [], + explanation: + "Readiness applies to active PRs. A completed PR is not scored as a new review opportunity.", + }; + } + + const changes = details ?? review?.details; + const signals: ReadinessSignal[] = [ + { + label: "PR state", + points: status === "draft" ? 0 : 20, + maximum: 20, + reason: status === "draft" ? "Still a draft" : "Open and not a draft", + }, + decisionSignal( + "Review", + review?.reviewDecision, + REVIEW_POINTS, + 30, + "Review decision unknown", + ), + decisionSignal( + "CI checks", + review?.checks, + CHECK_POINTS, + 25, + "Check status unknown", + ), + conflictSignal(changes?.mergeable), + sizeSignal(changes), + ]; + const blocked = + review?.reviewDecision === "CHANGES_REQUESTED" || + review?.checks === "FAILURE" || + review?.checks === "ERROR" || + changes?.mergeable === false; + const cap = readinessCap(status, blocked); + const earned = signals.reduce((sum, signal) => sum + (signal.points ?? 0), 0); + const unknown = signals.reduce( + (sum, signal) => sum + (signal.points === null ? signal.maximum : 0), + 0, + ); + const minimum = Math.min(cap.value, earned); + const maximum = Math.min(cap.value, earned + unknown); + const label = scoreLabel(status, blocked, unknown, minimum); + return { + minimum, + maximum, + coverage: 100 - unknown, + label, + signals, + explanation: cap.explanation, + }; +} diff --git a/src/features/pull-requests/server/api.ts b/src/features/pull-requests/server/api.ts new file mode 100644 index 0000000..59a7ec9 --- /dev/null +++ b/src/features/pull-requests/server/api.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { isSearchRateLimited } from "@/features/issues/server/search-rate-limit"; +import { isRateLimitError } from "@/lib/github"; + +export function limitPullRequestRequest( + request: Request, + category: "search" | "enrichment" | "details", +) { + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; + if (!isSearchRateLimited(`pr:${category}:${ip}`)) return null; + return NextResponse.json( + { error: "Too many requests. Try again in a minute.", retryAfter: 60 }, + { status: 429, headers: { "Retry-After": "60" } }, + ); +} + +export function pullRequestApiError(error: unknown) { + if (isRateLimitError(error)) { + const retryAfter = error.retryAfterSeconds ?? 60; + return NextResponse.json( + { error: error.message, retryAfter }, + { status: 429, headers: { "Retry-After": String(retryAfter) } }, + ); + } + return NextResponse.json( + { + error: + "GitHub data is unavailable. Check the organization and repository, or try again later.", + }, + { status: 502 }, + ); +} diff --git a/src/features/pull-requests/server/enrichment.ts b/src/features/pull-requests/server/enrichment.ts new file mode 100644 index 0000000..5e95371 --- /dev/null +++ b/src/features/pull-requests/server/enrichment.ts @@ -0,0 +1,255 @@ +import { + githubFetch, + computeRetryAfterSeconds, + RateLimitError, + isRateLimitError, +} from "@/lib/github"; +import { + getCommunityProfile, + getRepositoryResponsiveness, +} from "@/features/repositories/server/github-repository"; +import { scoreRepositoryHealth } from "@/features/issues/lib/repository-health"; +import { unknownRepositoryResponsiveness } from "@/features/issues/lib/repository-responsiveness"; +import type { GitHubRepo } from "@/features/issues/types/search"; +import type { + PullRequestDetails, + PullRequestEnrichment, + PullRequestReview, + RepositoryInsights, +} from "../types"; + +export type EnrichmentReference = { id: string; repository: string }; +type ReviewNode = { + id: string; + additions?: number; + deletions?: number; + changedFiles?: number; + mergeable?: "MERGEABLE" | "CONFLICTING" | "UNKNOWN"; + repository: { isPrivate: boolean }; + reviewDecision: string | null; + reviewRequests: { + nodes: Array<{ + requestedReviewer: { login?: string; name?: string } | null; + }>; + }; + commits: { + nodes: Array<{ commit: { statusCheckRollup: { state: string } | null } }>; + }; + closingIssuesReferences: { + nodes: Array<{ + title: string; + url: string; + repository: { isPrivate: boolean }; + }>; + }; +}; + +const REVIEW_QUERY = `query PullRequestInsights($ids: [ID!]!) { + nodes(ids: $ids) { ... on PullRequest { + id repository { isPrivate } + reviewDecision additions deletions changedFiles mergeable + reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { name } ... on Mannequin { login } } } } + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } + closingIssuesReferences(first: 10) { nodes { title url repository { isPrivate } } } + } } +}`; + +function reviewDetails(node: ReviewNode): PullRequestDetails | undefined { + if ( + node.additions == null || + node.deletions == null || + node.changedFiles == null + ) + return undefined; + let mergeable: boolean | null = null; + if (node.mergeable === "MERGEABLE") mergeable = true; + if (node.mergeable === "CONFLICTING") mergeable = false; + return { + additions: node.additions, + deletions: node.deletions, + changedFiles: node.changedFiles, + mergeable, + }; +} + +async function getReviews( + ids: string[], +): Promise> { + const reviews: Record = Object.fromEntries( + ids.map((id) => [id, null]), + ); + const token = process.env.GITHUB_TOKEN; + if (!token || !ids.length) return reviews; + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ query: REVIEW_QUERY, variables: { ids } }), + next: { revalidate: 60 }, + signal: AbortSignal.timeout(15000), + }); + if (response.status === 403 || response.status === 429) + throw new RateLimitError( + "GitHub detail requests are temporarily limited.", + computeRetryAfterSeconds(response.headers), + ); + if (!response.ok) throw new Error("PR reviews unavailable"); + const payload = (await response.json()) as { + data?: { nodes?: Array }; + errors?: Array<{ path?: Array; type?: string }>; + }; + if (payload.errors?.some((error) => error.type === "RATE_LIMITED")) + throw new RateLimitError("GitHub detail requests are temporarily limited."); + for (const [index, node] of (payload.data?.nodes ?? []).entries()) { + if ( + node?.repository?.isPrivate !== false || + payload.errors?.some((error) => !error.path || error.path[1] === index) + ) + continue; + reviews[node.id] = { + details: reviewDetails(node), + reviewDecision: node.reviewDecision, + reviewers: node.reviewRequests.nodes.flatMap(({ requestedReviewer }) => + requestedReviewer?.login || requestedReviewer?.name + ? [requestedReviewer.login ?? requestedReviewer.name!] + : [], + ), + checks: node.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null, + linkedIssues: node.closingIssuesReferences.nodes + .filter((issue) => issue.repository.isPrivate === false) + .map(({ title, url }) => ({ title, url })), + linkedIssueCount: node.closingIssuesReferences.nodes.filter( + (issue) => issue.repository.isPrivate === false, + ).length, + }; + } + return reviews; +} + +async function getInsights( + repository: string, +): Promise<{ insights: RepositoryInsights; rateLimited: boolean }> { + const token = process.env.GITHUB_TOKEN; + const { data } = await githubFetch( + `https://api.github.com/repos/${repository}`, + token, + 7200, + ); + if (data.private !== false) throw new Error("Public repository unavailable"); + const [profile, responsiveness] = await Promise.allSettled([ + getCommunityProfile(repository, token), + getRepositoryResponsiveness(repository, token), + ]); + return { + rateLimited: [profile, responsiveness].some( + (result) => + result.status === "rejected" && isRateLimitError(result.reason), + ), + insights: { + stars: data.stargazers_count, + health: scoreRepositoryHealth(data), + responsiveness: + responsiveness.status === "fulfilled" + ? responsiveness.value + : unknownRepositoryResponsiveness(), + documentation: + profile.status === "fulfilled" ? profile.value.documentation : null, + hacktoberfest: (data.topics ?? []).includes("hacktoberfest"), + }, + }; +} + +export async function enrichPullRequests( + references: EnrichmentReference[], +): Promise { + if (!process.env.GITHUB_TOKEN) { + return { + repositories: {}, + reviews: Object.fromEntries(references.map(({ id }) => [id, null])), + notices: [ + "Additional repository and review insights are unavailable. You can still search and explore pull requests.", + ], + }; + } + const names = [ + ...new Set(references.map((reference) => reference.repository)), + ].slice(0, 12); + const ids = [...new Set(references.map((reference) => reference.id))]; + const repositories: Record = {}; + const notices: string[] = []; + const reviewsPromise = getReviews(ids).catch((error: unknown) => { + notices.push( + isRateLimitError(error) + ? error.message + : "PR review details are unavailable. Basic results are still usable.", + ); + return Object.fromEntries(ids.map((id) => [id, null])); + }); + // Three repositories at once; each runs at most two optional requests concurrently. + for (let start = 0; start < names.length; start += 3) { + const batch = names.slice(start, start + 3); + const results = await Promise.allSettled(batch.map(getInsights)); + results.forEach((result, index) => { + if (result.status === "fulfilled") + repositories[batch[index]] = result.value.insights; + }); + if ( + results.some((result) => + result.status === "fulfilled" + ? result.value.rateLimited + : isRateLimitError(result.reason), + ) + ) { + notices.push( + "GitHub rate limited repository insights. Remaining insights are unknown.", + ); + break; + } + } + const reviews = await reviewsPromise; + if ( + names.length < + new Set(references.map((reference) => reference.repository)).size + ) + notices.push( + "Repository insights are limited to 12 unique repositories per page.", + ); + if ( + Object.keys(repositories).length < names.length || + Object.values(repositories).some( + (repo) => !repo.documentation || repo.responsiveness.status === "unknown", + ) || + Object.values(reviews).some((review) => !review) + ) + notices.push( + "Some insights are unavailable or have insufficient evidence; these are shown as Unknown.", + ); + return { repositories, reviews, notices }; +} + +export async function getPullRequestDetails( + repository: string, + number: number, +): Promise { + const { data } = await githubFetch<{ + base: { repo: { private: boolean } }; + additions: number; + deletions: number; + changed_files: number; + mergeable: boolean | null; + }>( + `https://api.github.com/repos/${repository}/pulls/${number}`, + process.env.GITHUB_TOKEN, + ); + if (data.base.repo.private !== false) + throw new Error("Public pull request unavailable"); + return { + additions: data.additions, + deletions: data.deletions, + changedFiles: data.changed_files, + mergeable: data.mergeable, + }; +} diff --git a/src/features/pull-requests/server/search.ts b/src/features/pull-requests/server/search.ts new file mode 100644 index 0000000..28f2cc2 --- /dev/null +++ b/src/features/pull-requests/server/search.ts @@ -0,0 +1,254 @@ +import { + LANGUAGE_ALIASES, + TOPIC_ALIASES, +} from "@/features/issues/data/search-options"; +import { githubFetch } from "@/lib/github"; +import linguistLanguages from "../data/languages.json"; +import type { GitHubRepo } from "@/features/issues/types/search"; +import type { + PullRequest, + PullRequestFilters, + PullRequestSearchResponse, +} from "../types"; + +const PAGE_SIZE = 24; +// GitHub Linguist names/aliases, bundled to avoid a runtime catalog request. +const LANGUAGES = new Map([ + ...Object.entries(linguistLanguages), + ...Object.entries(LANGUAGE_ALIASES), +]); +type SearchItem = { + node_id: string; + number: number; + title: string; + html_url: string; + repository_url: string; + user: { login: string } | null; + assignees?: Array<{ login: string }>; + state: "open" | "closed"; + draft?: boolean; + pull_request: { merged_at?: string | null }; + labels: Array<{ name: string }>; + comments: number; + created_at: string; + updated_at: string; +}; +type SearchResult = { + total_count: number; + incomplete_results?: boolean; + items: SearchItem[]; +}; +type PublicRepo = GitHubRepo & { private: boolean; language?: string }; + +function pullRequestStatus(item: SearchItem): PullRequest["status"] { + if (item.pull_request.merged_at) return "merged"; + if (item.state === "closed") return "closed"; + return item.draft ? "draft" : "open"; +} + +function mapPullRequest(item: SearchItem): PullRequest { + return { + id: item.node_id, + number: item.number, + title: item.title, + url: item.html_url, + repository: item.repository_url.split("/repos/")[1], + author: item.user?.login ?? "ghost", + assignees: (item.assignees ?? []).map((user) => user.login), + status: pullRequestStatus(item), + labels: item.labels.map((label) => label.name), + comments: item.comments, + createdAt: item.created_at, + updatedAt: item.updated_at, + }; +} + +async function searchPage( + query: string, + sort: string, + page: number, + size: number, +) { + const url = new URL("https://api.github.com/search/issues"); + url.search = new URLSearchParams({ + q: query, + sort, + order: "desc", + page: String(page), + per_page: String(size), + }).toString(); + return ( + await githubFetch(url.toString(), process.env.GITHUB_TOKEN) + ).data; +} + +const STATUS_QUERY = { + open: "is:open draft:false", + draft: "is:open draft:true", + merged: "is:merged", + closed: "is:closed is:unmerged", + all: "", +}; + +async function frameworkRepositories( + filters: PullRequestFilters, + topic: string, +) { + if (filters.repository) { + const { data } = await githubFetch( + `https://api.github.com/repos/${filters.repository}`, + process.env.GITHUB_TOKEN, + 7200, + ); + const matches = + !data.private && !data.archived && data.topics?.includes(topic); + return { + names: matches ? [data.full_name] : [], + total: matches ? 1 : 0, + incomplete: false, + }; + } + const url = new URL("https://api.github.com/search/repositories"); + url.search = new URLSearchParams({ + q: `org:${filters.org} topic:${topic} archived:false is:public`, + sort: "updated", + order: "desc", + per_page: "20", + }).toString(); + const { data } = await githubFetch<{ + items: PublicRepo[]; + total_count: number; + incomplete_results?: boolean; + }>(url.toString(), process.env.GITHUB_TOKEN, 7200); + return { + names: data.items + .filter((repo) => !repo.private) + .map((repo) => repo.full_name), + total: data.total_count, + incomplete: !!data.incomplete_results, + }; +} + +function repositoryQueries(base: string, names: string[]) { + const queries: string[] = []; + let query = base; + for (const name of names) { + const qualifier = ` repo:${name}`; + if ((query + qualifier).length > 256 && query !== base) { + queries.push(query); + query = base; + } + query += qualifier; + } + if (query !== base) queries.push(query); + return queries; +} + +async function buildPullRequestQueries( + filters: PullRequestFilters, +) { + const normalized = filters.tech.toLowerCase(); + const language = Object.hasOwn(TOPIC_ALIASES, normalized) + ? undefined + : LANGUAGES.get(normalized); + const base = + `is:pr is:public archived:false ${STATUS_QUERY[filters.status]}`.trim(); + const notices: string[] = []; + let queries: string[]; + if (language) { + const scope = filters.repository + ? `repo:${filters.repository}` + : `org:${filters.org}`; + queries = [`${base} ${scope} language:"${language}"`]; + } else { + const topic = + TOPIC_ALIASES[normalized]?.topic ?? normalized.replaceAll(/\s+/g, "-"); + const repositories = await frameworkRepositories(filters, topic); + queries = repositoryQueries(base, repositories.names); + notices.push( + `Technology matches repository topic “${topic}”, not individual changed files.`, + ); + if (repositories.total > repositories.names.length) + notices.push( + `Searching the ${repositories.names.length} most recently updated matching repositories out of ${repositories.total}. Choose a repository to narrow the search.`, + ); + if (repositories.incomplete) + notices.push("GitHub returned incomplete repository discovery results."); + } + return { queries, notices }; +} + +export async function searchPullRequests( + filters: PullRequestFilters, +): Promise { + const { queries, notices } = await buildPullRequestQueries(filters); + // Each repository group contributes its sorted prefix before the global page is selected. + // A single group can use GitHub pagination directly. + const prefixSize = filters.page * PAGE_SIZE; + const size = queries.length > 1 ? Math.min(100, prefixSize) : PAGE_SIZE; + const pages = queries.length > 1 ? Math.ceil(prefixSize / size) : 1; + const results: Array<{ + total: number; + incomplete: boolean; + items: SearchItem[]; + }> = []; + // Bound GitHub search concurrency even for organizations with long repository names. + for (let start = 0; start < queries.length; start += 3) { + const batch = await Promise.all( + queries.slice(start, start + 3).map(async (query) => { + const responses: SearchResult[] = []; + for (let index = 0; index < pages; index++) { + const response = await searchPage( + query, + filters.sort, + queries.length > 1 ? index + 1 : filters.page, + size, + ); + responses.push(response); + if (response.items.length < size) break; + } + return { + total: responses[0].total_count, + incomplete: responses.some((response) => response.incomplete_results), + items: responses.flatMap((response) => response.items), + }; + }), + ); + results.push(...batch); + } + const totalCount = results.reduce((sum, result) => sum + result.total, 0); + const items = Array.from( + new Map( + results + .flatMap((result) => result.items) + .map((item) => [item.node_id, item]), + ).values(), + ); + items.sort((a, b) => { + const difference = sortValue(b, filters.sort) - sortValue(a, filters.sort); + return difference || a.html_url.localeCompare(b.html_url); + }); + if (results.some((result) => result.incomplete)) + notices.push( + "GitHub returned incomplete PR results. Retry or narrow your search.", + ); + if (totalCount > 240) + notices.push( + "Showing up to 240 results. Narrow your search to explore more.", + ); + const start = queries.length > 1 ? (filters.page - 1) * PAGE_SIZE : 0; + return { + pullRequests: items.slice(start, start + PAGE_SIZE).map(mapPullRequest), + totalCount, + page: filters.page, + hasMore: filters.page < 10 && prefixSize < totalCount, + query: queries.join(" OR "), + notices, + tokenConfigured: !!process.env.GITHUB_TOKEN, + }; +} + +function sortValue(item: SearchItem, sort: PullRequestFilters["sort"]) { + if (sort === "comments") return item.comments; + return Date.parse(sort === "created" ? item.created_at : item.updated_at); +} diff --git a/src/features/pull-requests/types.ts b/src/features/pull-requests/types.ts new file mode 100644 index 0000000..1d74ee8 --- /dev/null +++ b/src/features/pull-requests/types.ts @@ -0,0 +1,71 @@ +import type { + ContributionDocumentation, + RepositoryHealth, + RepositoryResponsiveness, +} from "@/features/issues/types/search"; + +export const PR_STATUSES = [ + "open", + "draft", + "merged", + "closed", + "all", +] as const; +export const PR_SORTS = ["updated", "created", "comments"] as const; +export type PullRequestFilters = { + org: string; + tech: string; + repository: string; + status: (typeof PR_STATUSES)[number]; + sort: (typeof PR_SORTS)[number]; + page: number; +}; +export type PullRequest = { + id: string; + number: number; + title: string; + url: string; + repository: string; + author: string; + assignees: string[]; + status: "open" | "draft" | "merged" | "closed"; + labels: string[]; + comments: number; + createdAt: string; + updatedAt: string; +}; +export type PullRequestSearchResponse = { + pullRequests: PullRequest[]; + totalCount: number; + page: number; + hasMore: boolean; + query: string; + notices: string[]; + tokenConfigured: boolean; +}; +export type RepositoryInsights = { + stars: number | null; + health: RepositoryHealth; + responsiveness: RepositoryResponsiveness; + documentation: ContributionDocumentation | null; + hacktoberfest: boolean | null; +}; +export type PullRequestReview = { + details?: PullRequestDetails; + reviewDecision: string | null; + reviewers: string[]; + checks: string | null; + linkedIssues: Array<{ title: string; url: string }>; + linkedIssueCount: number; +}; +export type PullRequestEnrichment = { + repositories: Record; + reviews: Record; + notices: string[]; +}; +export type PullRequestDetails = { + additions: number; + deletions: number; + changedFiles: number; + mergeable: boolean | null; +}; diff --git a/src/features/repositories/server/github-repository.ts b/src/features/repositories/server/github-repository.ts new file mode 100644 index 0000000..d0a810b --- /dev/null +++ b/src/features/repositories/server/github-repository.ts @@ -0,0 +1,141 @@ +import { + githubFetch, + RateLimitError, + computeRetryAfterSeconds, + isRateLimitResponse, +} from "@/lib/github"; +import { + scoreRepositoryResponsiveness, + unknownRepositoryResponsiveness, + type ResponsivenessIssue, + type ResponsivenessPullRequest, +} from "@/features/issues/lib/repository-responsiveness"; + +type GitHubCommunityProfileResponse = { + health_percentage: number; + files: Partial< + Record< + | "readme" + | "contributing" + | "license" + | "code_of_conduct" + | "issue_template" + | "pull_request_template", + { html_url?: string | null } | null + > + >; +}; + +type GitHubResponsivenessResponse = { + data?: { + repository?: { + issues: { nodes: ResponsivenessIssue[] }; + pullRequests: { nodes: ResponsivenessPullRequest[] }; + } | null; + }; + errors?: Array<{ message: string }>; +}; + +const RESPONSIVENESS_QUERY = ` + query RepositoryResponsiveness($owner: String!, $name: String!, $since: DateTime!) { + repository(owner: $owner, name: $name) { + issues(first: 20, orderBy: { field: CREATED_AT, direction: DESC }, filterBy: { since: $since }) { + nodes { + author { login } + closedAt + createdAt + labels(first: 10) { nodes { name } } + comments(first: 20) { + nodes { author { login } authorAssociation createdAt } + } + } + } + pullRequests(first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { + nodes { authorAssociation createdAt mergedAt } + } + } + } +`; + +export async function getRepositoryResponsiveness( + fullName: string, + token = process.env.GITHUB_TOKEN, +) { + if (!token) { + return unknownRepositoryResponsiveness( + "Maintainer responsiveness data is unavailable", + ); + } + + const [owner, name] = fullName.split("/"); + if (!owner || !name) return unknownRepositoryResponsiveness(); + + const sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + sinceDate.setUTCHours(Math.floor(sinceDate.getUTCHours() / 6) * 6, 0, 0, 0); + const since = sinceDate.toISOString(); + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ + query: RESPONSIVENESS_QUERY, + variables: { owner, name, since }, + }), + next: { revalidate: 21600 }, + }); + + if (!response.ok) { + const body = await response.text(); + const retryAfterSeconds = computeRetryAfterSeconds(response.headers); + + if ( + (response.status === 403 || response.status === 429) && + isRateLimitResponse(body) + ) { + throw new RateLimitError( + "GitHub API rate limit exceeded. Please wait a few minutes and try again.", + retryAfterSeconds, + ); + } + + throw new Error(`GitHub GraphQL error ${response.status}`); + } + const payload = (await response.json()) as GitHubResponsivenessResponse; + const repository = payload.data?.repository; + + if (!repository || payload.errors?.length) { + throw new Error( + payload.errors?.[0]?.message ?? "Repository analytics unavailable", + ); + } + + return scoreRepositoryResponsiveness( + repository.issues.nodes, + repository.pullRequests.nodes, + ); +} + +export async function getCommunityProfile(fullName: string, token?: string) { + const result = await githubFetch( + `https://api.github.com/repos/${fullName}/community/profile`, + token, + 21600, + ); + const files = result.data.files ?? {}; + + return { + healthPercentage: result.data.health_percentage, + documentation: { + readme: files.readme?.html_url ?? null, + contributing: files.contributing?.html_url ?? null, + license: files.license?.html_url ?? null, + codeOfConduct: files.code_of_conduct?.html_url ?? null, + issueTemplate: files.issue_template?.html_url ?? null, + pullRequestTemplate: files.pull_request_template?.html_url ?? null, + }, + }; +} diff --git a/src/lib/github.ts b/src/lib/github.ts new file mode 100644 index 0000000..dea6081 --- /dev/null +++ b/src/lib/github.ts @@ -0,0 +1,121 @@ +export class RateLimitError extends Error { + retryAfterSeconds: number | null; + + constructor(message: string, retryAfterSeconds: number | null = null) { + super(message); + this.name = "RateLimitError"; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +export function isRateLimitError(error: unknown): error is RateLimitError { + return error instanceof RateLimitError; +} + +function isAllowedGitHubPath(pathname: string) { + if (pathname === "/search/issues" || pathname === "/search/repositories") return true; + const [, resource, owner = "", name = "", ...remaining] = pathname.split("/"); + if ( + resource !== "repos" || + !/^[a-zA-Z0-9-]+$/.test(owner) || + !/^[a-zA-Z0-9_.-]+$/.test(name) || + name === "." || name === ".." + ) return false; + const suffix = remaining.length ? "/" + remaining.join("/") : ""; + if (suffix === "" || suffix === "/community/profile") return true; + return /^\/issues\/[1-9]\d*\/(?:comments|timeline)$/.test(suffix) || + /^\/pulls\/[1-9]\d*$/.test(suffix); +} + +export async function githubFetch( + url: string, + token?: string, + revalidate = 60, +) { + const target = new URL(url); + if ( + target.protocol !== "https:" || + target.hostname !== "api.github.com" || + target.port || + target.username || + target.password + ) { + throw new Error("Only the GitHub HTTPS API is allowed."); + } + if (!isAllowedGitHubPath(target.pathname)) { + throw new Error("Unsupported GitHub API endpoint."); + } + // Construct the destination from a fixed origin. Encode every input-bearing + // component instead of forwarding the caller's URL to the network boundary. + const path = target.pathname.slice(1).split("/").map((segment) => encodeURIComponent(segment)).join("/"); + const query = Array.from(target.searchParams, ([key, value]) => + encodeURIComponent(key) + "=" + encodeURIComponent(value), + ).join("&"); + const requestUrl = "https://api.github.com/" + path + (query ? "?" + query : ""); + const response = await fetch(requestUrl, { + redirect: "error", + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + next: { revalidate }, + }); + + if (!response.ok) { + const body = await response.text(); + const retryAfterSeconds = computeRetryAfterSeconds(response.headers); + + if ( + (response.status === 403 || response.status === 429) && + isRateLimitResponse(body) + ) { + throw new RateLimitError( + "GitHub API rate limit exceeded. Please wait a few minutes and try again.", + retryAfterSeconds, + ); + } + + throw new Error(`GitHub API error ${response.status}: ${body}`); + } + + return { + data: (await response.json()) as T, + rateLimitRemaining: response.headers.get("x-ratelimit-remaining"), + }; +} + +export function isRateLimitResponse(body: string): boolean { + const lower = body.toLowerCase(); + return ( + lower.includes("rate limit") || + lower.includes("rate_limit") || + lower.includes("api rate limit exceeded") || + lower.includes("secondary rate limit") + ); +} + +// GitHub's primary rate-limit responses commonly omit `retry-after` and +// instead provide `x-ratelimit-reset`, a Unix timestamp (seconds) for when +// the limit resets. Fall back to computing the delay from that header so we +// don't under-report the wait time with a default cooldown. +export function computeRetryAfterSeconds(headers: Headers): number | null { + const retryAfter = headers.get("retry-after"); + if (retryAfter) { + const parsed = Number.parseInt(retryAfter, 10); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + + const resetHeader = headers.get("x-ratelimit-reset"); + if (resetHeader) { + const resetEpochSeconds = Number.parseInt(resetHeader, 10); + if (!Number.isNaN(resetEpochSeconds)) { + const nowEpochSeconds = Math.floor(Date.now() / 1000); + return Math.max(0, resetEpochSeconds - nowEpochSeconds); + } + } + + return null; +} diff --git a/tests/features/issues/components/issue-finder.test.tsx b/tests/features/issues/components/issue-finder.test.tsx index 7c8d5a9..e63e88e 100644 --- a/tests/features/issues/components/issue-finder.test.tsx +++ b/tests/features/issues/components/issue-finder.test.tsx @@ -755,7 +755,7 @@ describe("IssueFinder", () => { expect(await screen.findByText("Failed to load more issues.")).toBeTruthy(); }); - it("shows an unknown token status until a successful search reports it", async () => { + it("keeps server configuration out of the search overview", async () => { const fetchMock = vi.mocked(fetch); fetchMock .mockResolvedValueOnce( @@ -766,19 +766,21 @@ describe("IssueFinder", () => { ); render(); - expect(screen.getByText("unknown")).toBeTruthy(); + expect(screen.queryByText("GitHub token")).toBeNull(); const form = screen .getByRole("button", { name: "Search" }) .closest("form")!; fireEvent.submit(form); - expect(await screen.findByText("configured")).toBeTruthy(); + await screen.findByText("Opportunities"); + expect(screen.queryByText("GitHub token")).toBeNull(); + expect(screen.queryByText("configured")).toBeNull(); fireEvent.submit(form); expect( (await screen.findAllByText("Search failed")).length, ).toBeGreaterThan(0); - expect(screen.getByText("configured")).toBeTruthy(); + expect(screen.queryByText("GitHub token")).toBeNull(); }); it("writes successful searches to the URL without adding pagination entries", async () => { diff --git a/tests/features/issues/server/github-search.test.ts b/tests/features/issues/server/github-search.test.ts index d6145c9..08443d9 100644 --- a/tests/features/issues/server/github-search.test.ts +++ b/tests/features/issues/server/github-search.test.ts @@ -885,8 +885,8 @@ describe("repository digest GitHub queries", () => { stars: 250, }, ]); - expect(String(fetchMock.mock.calls[0][0])).toContain( - "widgets+in%3Aname%2Cdescription+archived%3Afalse", + expect(new URL(String(fetchMock.mock.calls[0][0])).searchParams.get("q")).toBe( + "widgets in:name,description archived:false", ); }); diff --git a/tests/features/pull-requests/api.test.ts b/tests/features/pull-requests/api.test.ts new file mode 100644 index 0000000..0b8ba79 --- /dev/null +++ b/tests/features/pull-requests/api.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RateLimitError } from "@/lib/github"; +const mocks = vi.hoisted(() => ({ + search: vi.fn(), + enrich: vi.fn(), + details: vi.fn(), + limited: vi.fn(), +})); +vi.mock("@/features/pull-requests/server/search", () => ({ + searchPullRequests: mocks.search, +})); +vi.mock("@/features/pull-requests/server/enrichment", () => ({ + enrichPullRequests: mocks.enrich, + getPullRequestDetails: mocks.details, +})); +vi.mock("@/features/issues/server/search-rate-limit", () => ({ + isSearchRateLimited: mocks.limited, +})); +import { GET } from "@/app/api/pull-requests/route"; +import { POST } from "@/app/api/pull-requests/enrichment/route"; +import { GET as details } from "@/app/api/pull-requests/details/route"; + +beforeEach(() => { + vi.resetAllMocks(); + mocks.search.mockResolvedValue({ pullRequests: [] }); + mocks.enrich.mockResolvedValue({ repositories: {}, reviews: {} }); + mocks.details.mockResolvedValue({ additions: 1 }); +}); +const searchRequest = () => + new Request("http://localhost/api/pull-requests?org=acme&tech=React", { + headers: { "x-forwarded-for": "203.0.113.9, 1.1.1.1" }, + }); +const post = (body: unknown) => + new Request("http://localhost/api/pull-requests/enrichment", { + method: "POST", + body: JSON.stringify(body), + }); +const detailRequest = () => + new Request( + "http://localhost/api/pull-requests/details?repository=acme/widgets&number=42", + ); + +describe("PR routes", () => { + it("validates filters before doing work", async () => { + expect( + ( + await GET( + new Request( + "http://localhost/api/pull-requests?org=acme&tech=x&repository=other/private", + ), + ) + ).status, + ).toBe(400); + expect(mocks.search).not.toHaveBeenCalled(); + }); + it("passes validated search filters and shares IP-based limits", async () => { + expect((await GET(searchRequest())).status).toBe(200); + expect(mocks.search).toHaveBeenCalledWith({ + org: "acme", + tech: "React", + repository: "", + status: "open", + sort: "updated", + page: 1, + }); + expect(mocks.limited).toHaveBeenCalledWith("pr:search:203.0.113.9"); + }); + it("enriches valid references and loads individual changes", async () => { + const references = [{ id: "PR_1", repository: "acme/widgets" }]; + expect((await POST(post(references))).status).toBe(200); + expect(mocks.enrich).toHaveBeenCalledWith(references); + expect((await details(detailRequest())).status).toBe(200); + expect(mocks.details).toHaveBeenCalledWith("acme/widgets", 42); + }); + it.each([ + [], + {}, + [null], + [{ id: "!", repository: "acme/widgets" }], + [{ id: "PR_1", repository: "../secret" }], + Array(25).fill({ id: "PR_1", repository: "acme/widgets" }), + ])("rejects invalid enrichment %j", async (body) => { + expect((await POST(post(body))).status).toBe(400); + expect(mocks.enrich).not.toHaveBeenCalled(); + }); + it("rejects malformed and oversized JSON", async () => { + expect( + ( + await POST( + new Request("http://localhost", { method: "POST", body: "{" }), + ) + ).status, + ).toBe(400); + expect((await POST(post("x".repeat(12001)))).status).toBe(400); + }); + it.each([ + "repository=bad&number=1", + "repository=acme/widgets&number=-1", + "repository=acme/widgets&number=1.5", + ])("rejects invalid details %s", async (params) => { + expect( + ( + await details( + new Request(`http://localhost/api/pull-requests/details?${params}`), + ) + ).status, + ).toBe(400); + }); + it.each(["search", "enrich", "details"] as const)( + "handles %s limits and upstream failures", + async (service) => { + const run = () => + service === "search" + ? GET(searchRequest()) + : service === "enrich" + ? POST(post([{ id: "PR_1", repository: "acme/widgets" }])) + : details(detailRequest()); + mocks.limited.mockReturnValueOnce(true); + const limited = await run(); + expect(limited.status).toBe(429); + expect(limited.headers.get("retry-after")).toBe("60"); + mocks[service].mockRejectedValueOnce( + new RateLimitError("Rate limited", 120), + ); + const upstream = await run(); + expect(upstream.status).toBe(429); + expect(upstream.headers.get("retry-after")).toBe("120"); + mocks[service].mockRejectedValueOnce(new Error("private error details")); + const failure = await run(); + expect(failure.status).toBe(502); + expect(JSON.stringify(await failure.json())).not.toContain( + "private error", + ); + }, + ); +}); diff --git a/tests/features/pull-requests/dashboard.test.tsx b/tests/features/pull-requests/dashboard.test.tsx new file mode 100644 index 0000000..f2091d2 --- /dev/null +++ b/tests/features/pull-requests/dashboard.test.tsx @@ -0,0 +1,355 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const navigation = vi.hoisted(() => ({ query: "", push: vi.fn() })); +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(navigation.query), + useRouter: () => ({ push: navigation.push }), +})); +vi.mock("@/components/auth-controls", () => ({ AuthControls: () => null })); +vi.mock("@/components/theme-toggle", () => ({ ThemeToggle: () => null })); +import { PullRequestDashboard } from "@/features/pull-requests/components/pull-request-dashboard"; +import { PullRequestCard } from "@/features/pull-requests/components/pull-request-card"; +import type { PullRequest } from "@/features/pull-requests/types"; + +const pr: PullRequest = { + id: "PR_1", + number: 1, + title: "Improve widget rendering", + url: "https://github.com/acme/widgets/pull/1", + repository: "acme/widgets", + author: "alice", + assignees: ["bob"], + status: "open", + labels: ["bug"], + comments: 3, + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-02T00:00:00Z", +}; +const result = { + pullRequests: [pr], + totalCount: 50, + page: 1, + hasMore: true, + query: "is:pr", + notices: [], + tokenConfigured: true, +}; +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status }); +beforeEach(() => { + navigation.query = ""; + navigation.push.mockReset(); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("PR dashboard", () => { + it("shows an empty state and submits bookmarkable organization and technology filters", () => { + render(); + expect( + screen.getByText("Explore pull requests across an organization"), + ).toBeTruthy(); + fireEvent.change(screen.getByLabelText("Organization"), { + target: { value: "acme" }, + }); + fireEvent.change(screen.getByLabelText("Technology"), { + target: { value: "React" }, + }); + fireEvent.change(screen.getByLabelText("Repository (optional)"), { + target: { value: "acme/widgets" }, + }); + fireEvent.change(screen.getByLabelText("Status"), { + target: { value: "merged" }, + }); + fireEvent.change(screen.getByLabelText("Sort by"), { + target: { value: "created" }, + }); + fireEvent.submit( + screen + .getByRole("button", { name: "Search pull requests" }) + .closest("form")!, + ); + expect(navigation.push).toHaveBeenCalledWith( + "/pull-requests?org=acme&tech=React&repository=acme%2Fwidgets&status=merged&sort=created&page=1", + { scroll: false }, + ); + }); + it("restores URL filters and displays basic results while enrichment is pending", async () => { + navigation.query = "org=acme&tech=React&status=open&sort=updated&page=1"; + let finish!: (value: Response) => void; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(json(result)) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + ); + render(); + expect( + await screen.findByRole("link", { name: /Improve widget rendering/ }), + ).toBeTruthy(); + expect(screen.getByText("Loading repository insights…")).toBeTruthy(); + expect(screen.getByLabelText("Organization")).toHaveProperty( + "value", + "acme", + ); + await act(async () => + finish( + json({ + repositories: {}, + reviews: { PR_1: null }, + notices: ["Partial insights"], + }), + ), + ); + expect(screen.getByText("Review: Unknown")).toBeTruthy(); + expect(screen.getByText("Partial insights")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + expect(navigation.push.mock.calls[0][0]).toContain("page=2"); + }); + it("aborts stale requests when the URL changes and ignores late results", async () => { + navigation.query = "org=acme&tech=React"; + let finish!: (value: Response) => void; + const fetch = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue( + json({ ...result, pullRequests: [], totalCount: 0, hasMore: false }), + ); + vi.stubGlobal("fetch", fetch); + const view = render(); + const signal = fetch.mock.calls[0][1].signal as AbortSignal; + navigation.query = "org=acme&tech=Python"; + view.rerender(); + expect(signal.aborted).toBe(true); + expect(await screen.findByText(/No matching pull requests/)).toBeTruthy(); + await act(async () => finish(json(result))); + expect( + screen.queryByRole("link", { name: /Improve widget rendering/ }), + ).toBeNull(); + expect(screen.getByLabelText("Technology")).toHaveProperty( + "value", + "Python", + ); + }); + it("aborts an ongoing search when editing filters", async () => { + navigation.query = "org=acme&tech=React"; + const fetch = vi.fn().mockImplementation(() => new Promise(() => {})); + vi.stubGlobal("fetch", fetch); + render(); + fireEvent.change(screen.getByLabelText("Technology"), { + target: { value: "Python" }, + }); + expect(fetch.mock.calls[0][1].signal.aborted).toBe(true); + expect( + screen.getByRole("button", { name: "Search pull requests" }), + ).toHaveProperty("disabled", false); + }); + it("shows invalid URL errors without fetching", async () => { + navigation.query = "org=acme&tech=React&page=99"; + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + render(); + expect(await screen.findByRole("alert")).toHaveProperty( + "textContent", + expect.stringContaining("Page must"), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + it("keeps results usable when enrichment fails", async () => { + navigation.query = "org=acme&tech=React"; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(json(result)) + .mockResolvedValueOnce( + json({ error: "Details temporarily unavailable" }, 502), + ), + ); + render(); + expect( + await screen.findByText("Details temporarily unavailable"), + ).toBeTruthy(); + expect( + screen.getByRole("link", { name: /Improve widget rendering/ }), + ).toBeTruthy(); + }); + it("pauses retries after a rate-limit response", async () => { + navigation.query = "org=acme&tech=React"; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue(json({ error: "Rate limited", retryAfter: 1 }, 429)), + ); + render(); + expect( + await screen.findByText("Search is paused for 1 seconds."), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Search pull requests" }), + ).toHaveProperty("disabled", true); + await waitFor( + () => + expect( + screen.getByRole("button", { name: "Search pull requests" }), + ).toHaveProperty("disabled", false), + { timeout: 2000 }, + ); + }); + it("retries a failed search without changing its URL", async () => { + navigation.query = "org=acme&tech=React&status=open&sort=updated&page=1"; + const fetch = vi + .fn() + .mockResolvedValueOnce(json({ error: "Unavailable" }, 502)) + .mockResolvedValueOnce(json({ ...result, pullRequests: [] })); + vi.stubGlobal("fetch", fetch); + render(); + fireEvent.click( + await screen.findByRole("button", { name: "Retry search" }), + ); + expect(await screen.findByText(/No matching pull requests/)).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); + +describe("PR card", () => { + it("updates its score after enrichment and reuses batched change details", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + const view = render(); + expect(screen.getByText("PR readiness: 20–100/100")).toBeTruthy(); + view.rerender( + , + ); + expect(screen.getByText("PR readiness: 100/100")).toBeTruthy(); + fireEvent.click(screen.getByText("How this score works")); + expect(screen.getByText(/Review: 30\/30/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Change details" })); + expect(screen.getByText("1 changed files")).toBeTruthy(); + expect(fetch).not.toHaveBeenCalled(); + }); + it("marks completed PR readiness as not applicable", () => { + render( + , + ); + expect(screen.getByText("PR readiness: N/A")).toBeTruthy(); + expect(screen.getByText("Not applicable")).toBeTruthy(); + }); + it("loads changes only when expanded and reuses them", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + json({ additions: 12, deletions: 2, changedFiles: 3, mergeable: null }), + ); + vi.stubGlobal("fetch", fetch); + render(); + expect(fetch).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Change details" })); + expect(await screen.findByText("3 changed files")).toBeTruthy(); + expect(screen.getByText("Mergeability: Unknown")).toBeTruthy(); + expect(screen.getByText("PR readiness: 30–100/100")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Hide details" })); + fireEvent.click(screen.getByRole("button", { name: "Change details" })); + expect(fetch).toHaveBeenCalledTimes(1); + }); + it("shows shared repository insights, document links, and PR reviews", () => { + render( + , + ); + expect(screen.getByText("Review: Approved")).toBeTruthy(); + expect(screen.getByText("Checks: Passing")).toBeTruthy(); + expect(screen.getByText("Repository health: 80 · active")).toBeTruthy(); + expect(screen.getByText("Hacktoberfest repository topic")).toBeTruthy(); + fireEvent.click( + screen.getByText("Repository signals and contribution guides"), + ); + expect(screen.getByRole("link", { name: "README" })).toBeTruthy(); + expect(screen.getByText("Contributing guide: Not provided")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Fix widget" })).toBeTruthy(); + }); + it("shows errors for unavailable change details", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Offline"))); + render(); + fireEvent.click(screen.getByRole("button", { name: "Change details" })); + expect(await screen.findByRole("alert")).toHaveProperty( + "textContent", + expect.stringContaining("Offline"), + ); + }); +}); diff --git a/tests/features/pull-requests/enrichment.test.ts b/tests/features/pull-requests/enrichment.test.ts new file mode 100644 index 0000000..99f730e --- /dev/null +++ b/tests/features/pull-requests/enrichment.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + enrichPullRequests, + getPullRequestDetails, +} from "@/features/pull-requests/server/enrichment"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status }); +const reference = { id: "PR_1", repository: "acme/widgets" }; +const review = { + id: "PR_1", + repository: { isPrivate: false }, + reviewDecision: "APPROVED", + reviewRequests: { + nodes: [ + { requestedReviewer: { login: "alice" } }, + { requestedReviewer: { name: "Maintainers" } }, + { requestedReviewer: null }, + ], + }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + closingIssuesReferences: { + totalCount: 1, + nodes: [ + { + title: "Fix widgets", + url: "https://github.com/acme/widgets/issues/2", + repository: { isPrivate: false }, + }, + ], + }, +}; +function mockFetch(input: string, options?: RequestInit) { + if (input.endsWith("graphql")) { + const body = JSON.parse(options!.body as string); + if (body.variables.ids) return json({ data: { nodes: [review] } }); + return json({ + data: { + repository: { issues: { nodes: [] }, pullRequests: { nodes: [] } }, + }, + }); + } + if (input.endsWith("community/profile")) + return json({ + health_percentage: 80, + files: { readme: { html_url: "https://github.com/acme/widgets#readme" } }, + }); + return json({ + private: false, + stargazers_count: 100, + topics: ["hacktoberfest"], + pushed_at: new Date().toISOString(), + }); +} +beforeEach(() => vi.stubEnv("GITHUB_TOKEN", "test-token")); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("PR enrichment", () => { + it("never exposes a connection total that may include private issues beyond the sample", async () => { + const publicNodes = Array.from({ length: 10 }, (_, index) => ({ + title: `Public issue ${index}`, + url: `https://github.com/acme/widgets/issues/${index + 1}`, + repository: { isPrivate: false }, + })); + vi.stubGlobal( + "fetch", + vi.fn((input: string, options?: RequestInit) => { + if (options?.body?.toString().includes("PullRequestInsights")) { + expect(options.body.toString()).not.toContain("totalCount"); + return json({ + data: { + nodes: [ + { + ...review, + closingIssuesReferences: { + totalCount: 1234, + nodes: publicNodes, + }, + }, + ], + }, + }); + } + return mockFetch(input, options); + }), + ); + const result = await enrichPullRequests([reference]); + expect(result.reviews.PR_1?.linkedIssueCount).toBe(10); + expect(JSON.stringify(result)).not.toContain("1234"); + }); + it.each([ + ["MERGEABLE", true], + ["CONFLICTING", false], + ["UNKNOWN", null], + ] as const)( + "includes batched scoring details with %s mergeability", + async (mergeable, expected) => { + const fetch = vi.fn((input: string, options?: RequestInit) => { + if (options?.body?.toString().includes("PullRequestInsights")) { + return json({ + data: { + nodes: [ + { + ...review, + additions: 30, + deletions: 5, + changedFiles: 2, + mergeable, + }, + ], + }, + }); + } + return mockFetch(input, options); + }); + vi.stubGlobal("fetch", fetch); + const result = await enrichPullRequests([reference]); + expect(result.reviews.PR_1?.details).toEqual({ + additions: 30, + deletions: 5, + changedFiles: 2, + mergeable: expected, + }); + expect(fetch).toHaveBeenCalledTimes(4); + expect(fetch.mock.calls.some(([url]) => url.includes("/pulls/"))).toBe( + false, + ); + }, + ); + it("deduplicates repositories, batches reviews, and includes shared insights", async () => { + const fetch = vi.fn(mockFetch); + vi.stubGlobal("fetch", fetch); + const result = await enrichPullRequests([ + reference, + { ...reference, id: "PR_2" }, + ]); + expect( + fetch.mock.calls.filter( + ([url]) => url === "https://api.github.com/repos/acme/widgets", + ), + ).toHaveLength(1); + expect(result.repositories[reference.repository]).toMatchObject({ + stars: 100, + hacktoberfest: true, + }); + expect(result.reviews.PR_1).toMatchObject({ + reviewDecision: "APPROVED", + reviewers: ["alice", "Maintainers"], + checks: "SUCCESS", + linkedIssueCount: 1, + }); + expect(result.reviews.PR_2).toBeNull(); + const request = fetch.mock.calls.find(([, options]) => + options?.body?.toString().includes("PullRequestInsights"), + ); + expect(JSON.parse(request![1]!.body as string).variables.ids).toEqual([ + "PR_1", + "PR_2", + ]); + }); + it("limits insights to 12 repositories", async () => { + vi.stubGlobal("fetch", vi.fn(mockFetch)); + const result = await enrichPullRequests( + Array.from({ length: 13 }, (_, index) => ({ + id: `PR_${index}`, + repository: `acme/repo${index}`, + })), + ); + expect(Object.keys(result.repositories)).toHaveLength(12); + expect(result.notices.join(" ")).toContain("limited to 12"); + }); + it("works without a token, leaving authenticated insights unknown", async () => { + vi.stubEnv("GITHUB_TOKEN", ""); + const fetch = vi.fn(mockFetch); + vi.stubGlobal("fetch", fetch); + const result = await enrichPullRequests([reference]); + expect(fetch).not.toHaveBeenCalled(); + expect(result.repositories).toEqual({}); + expect(result.reviews.PR_1).toBeNull(); + expect(result.notices.join(" ")).toContain("insights are unavailable"); + expect(result.notices.join(" ")).not.toMatch(/token|configur/i); + }); + it("does not expose private repository, PR, or linked issue information", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: string) => + input.endsWith("graphql") + ? json({ + data: { nodes: [{ ...review, repository: { isPrivate: true } }] }, + }) + : json({ private: true, stargazers_count: 999 }), + ), + ); + const result = await enrichPullRequests([reference]); + expect(result.repositories).toEqual({}); + expect(result.reviews.PR_1).toBeNull(); + vi.stubGlobal( + "fetch", + vi.fn((input: string, options?: RequestInit) => + input.endsWith("graphql") && + options?.body?.toString().includes("PullRequestInsights") + ? json({ + data: { + nodes: [ + { + ...review, + closingIssuesReferences: { + totalCount: 2, + nodes: [ + { + title: "secret", + url: "secret", + repository: { isPrivate: true }, + }, + ...review.closingIssuesReferences.nodes, + ], + }, + }, + ], + }, + }) + : mockFetch(input, options), + ), + ); + const publicResult = await enrichPullRequests([reference]); + expect(publicResult.reviews.PR_1!.linkedIssues).toHaveLength(1); + expect(JSON.stringify(publicResult)).not.toContain("secret"); + }); + it("keeps basic repository data when optional services fail", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: string, options?: RequestInit) => + input.endsWith("graphql") || input.endsWith("profile") + ? json({}, 502) + : mockFetch(input, options), + ), + ); + const result = await enrichPullRequests([reference]); + expect(result.repositories[reference.repository].stars).toBe(100); + expect(result.repositories[reference.repository].documentation).toBeNull(); + expect(result.reviews.PR_1).toBeNull(); + expect(result.notices.join(" ")).toContain("unavailable"); + }); + it("stops repository batches on rate limiting", async () => { + const fetch = vi + .fn() + .mockResolvedValue(json({ message: "rate limit exceeded" }, 429)); + vi.stubGlobal("fetch", fetch); + const result = await enrichPullRequests( + Array.from({ length: 12 }, (_, index) => ({ + id: `PR_${index}`, + repository: `acme/repo${index}`, + })), + ); + expect(fetch).toHaveBeenCalledTimes(4); + expect(result.notices.join(" ")).toContain("rate limited"); + }); + it("treats partial GraphQL fields as unknown", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: string, options?: RequestInit) => + input.endsWith("graphql") + ? json({ + data: { nodes: [review] }, + errors: [{ path: ["nodes", 0, "reviewDecision"] }], + }) + : mockFetch(input, options), + ), + ); + expect((await enrichPullRequests([reference])).reviews.PR_1).toBeNull(); + }); + it("returns changes on demand and preserves unknown mergeability", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + json({ + base: { repo: { private: false } }, + additions: 12, + deletions: 3, + changed_files: 2, + mergeable: null, + }), + ), + ); + expect(await getPullRequestDetails("acme/widgets", 1)).toEqual({ + additions: 12, + deletions: 3, + changedFiles: 2, + mergeable: null, + }); + }); + it("rejects private change details", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(json({ base: { repo: { private: true } } })), + ); + await expect(getPullRequestDetails("acme/widgets", 1)).rejects.toThrow( + "Public pull request unavailable", + ); + }); +}); diff --git a/tests/features/pull-requests/scoring.test.ts b/tests/features/pull-requests/scoring.test.ts new file mode 100644 index 0000000..7ca6d2a --- /dev/null +++ b/tests/features/pull-requests/scoring.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { scorePullRequest } from "@/features/pull-requests/scoring"; +import type { PullRequestReview } from "@/features/pull-requests/types"; + +const ready: PullRequestReview = { + reviewDecision: "APPROVED", + checks: "SUCCESS", + reviewers: [], + linkedIssues: [], + linkedIssueCount: 0, + details: { additions: 100, deletions: 100, changedFiles: 5, mergeable: true }, +}; + +describe("PR readiness scoring", () => { + it("scores complete, approved, passing, conflict-free changes at 100", () => { + expect(scorePullRequest("open", ready)).toMatchObject({ + minimum: 100, + maximum: 100, + coverage: 100, + label: "Ready", + }); + }); + it("shows a range without treating unavailable insights as failures", () => { + const score = scorePullRequest("open"); + expect(score).toMatchObject({ + minimum: 20, + maximum: 100, + coverage: 20, + label: "Partial", + }); + expect( + score.signals.filter((signal) => signal.points === null), + ).toHaveLength(4); + }); + it("keeps unknown review decisions and CI states unscored", () => { + expect( + scorePullRequest("open", { + ...ready, + reviewDecision: "UNRECOGNIZED", + checks: "UNRECOGNIZED", + }), + ).toMatchObject({ + minimum: 45, + maximum: 100, + coverage: 45, + label: "Partial", + }); + }); + it("caps drafts even with otherwise perfect signals", () => { + expect(scorePullRequest("draft", ready)).toMatchObject({ + minimum: 39, + maximum: 39, + label: "Draft", + }); + expect(scorePullRequest("draft")).toMatchObject({ + minimum: 0, + maximum: 39, + coverage: 20, + }); + }); + it.each([ + { ...ready, reviewDecision: "CHANGES_REQUESTED" }, + { ...ready, checks: "FAILURE" }, + { ...ready, checks: "ERROR" }, + { ...ready, details: { ...ready.details!, mergeable: false } }, + ])("never labels a known blocker as ready", (review) => { + expect(scorePullRequest("open", review)).toMatchObject({ + minimum: 49, + maximum: 49, + label: "Blocked", + }); + }); + it.each(["PENDING", "EXPECTED"])( + "gives pending checks partial credit: %s", + (checks) => { + expect( + scorePullRequest("open", { + ...ready, + checks, + reviewDecision: "REVIEW_REQUIRED", + }), + ).toMatchObject({ minimum: 60, maximum: 60, label: "Needs attention" }); + }, + ); + it.each([ + [200, 5, 10], + [201, 5, 5], + [200, 6, 5], + [1000, 20, 5], + [1001, 20, 0], + [1000, 21, 0], + ])( + "scores review effort at size boundaries (%i lines, %i files)", + (additions, changedFiles, points) => { + const result = scorePullRequest("open", ready, { + additions, + deletions: 0, + changedFiles, + mergeable: true, + }); + expect( + result.signals.find((signal) => signal.label === "Review size")?.points, + ).toBe(points); + }, + ); + it("treats invalid size and unknown mergeability as missing evidence", () => { + expect( + scorePullRequest("open", ready, { + additions: -1, + deletions: 0, + changedFiles: 1, + mergeable: null, + }), + ).toMatchObject({ + minimum: 75, + maximum: 100, + coverage: 75, + label: "Partial", + }); + }); + it("refines the range when on-demand details arrive", () => { + expect(scorePullRequest("open", null, ready.details)).toMatchObject({ + minimum: 45, + maximum: 100, + coverage: 45, + }); + expect( + scorePullRequest("open", ready, { ...ready.details!, mergeable: false }) + .label, + ).toBe("Blocked"); + }); + it.each(["merged", "closed"] as const)( + "does not misrepresent %s PRs as active opportunities", + (status) => { + expect(scorePullRequest(status, ready)).toMatchObject({ + minimum: null, + maximum: null, + label: "Not applicable", + signals: [], + }); + }, + ); +}); diff --git a/tests/features/pull-requests/search.test.ts b/tests/features/pull-requests/search.test.ts new file mode 100644 index 0000000..b2129f2 --- /dev/null +++ b/tests/features/pull-requests/search.test.ts @@ -0,0 +1,264 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { searchPullRequests } from "@/features/pull-requests/server/search"; +import { + DEFAULT_PR_FILTERS, + readPullRequestFilters, + validatePullRequestFilters, + pullRequestSearchParams, +} from "@/features/pull-requests/filters"; + +const filters = { ...DEFAULT_PR_FILTERS, org: "acme", tech: "TypeScript" }; +const json = (body: unknown) => new Response(JSON.stringify(body)); +const item = (number: number, overrides = {}) => ({ + node_id: `PR_${number}`, + number, + title: `PR ${number}`, + html_url: `https://github.com/acme/widgets/pull/${number}`, + repository_url: "https://api.github.com/repos/acme/widgets", + user: { login: "alice" }, + assignees: [{ login: "bob" }], + state: "open", + labels: [{ name: "bug" }], + comments: number, + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-02T00:00:00Z", + pull_request: {}, + ...overrides, +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("PR filters", () => { + it("round-trips bookmarkable filters", () => { + expect( + readPullRequestFilters( + pullRequestSearchParams({ + ...filters, + repository: "acme/widgets", + page: 2, + }), + ), + ).toEqual({ ...filters, repository: "acme/widgets", page: 2 }); + expect(readPullRequestFilters(new URLSearchParams())).toEqual( + DEFAULT_PR_FILTERS, + ); + expect(validatePullRequestFilters(filters)).toBeNull(); + }); + it.each([ + { org: "acme is:private" }, + { org: "" }, + { tech: "" }, + { tech: "topic:x" }, + { tech: "x".repeat(81) }, + { repository: "other/widgets" }, + { repository: "acme/../secret" }, + { repository: "acme/.." }, + { repository: "acme/." }, + { status: "invalid" }, + { sort: "invalid" }, + { page: 0 }, + { page: 11 }, + { page: 1.5 }, + ])("rejects invalid filters %j", (change) => { + expect( + validatePullRequestFilters({ ...filters, ...change } as typeof filters), + ).toBeTruthy(); + }); +}); + +describe("PR search", () => { + it.each([ + "PHP", + "Kotlin", + "Swift", + "Dart", + "C", + "Haskell", + "Elixir", + "Objective-C", + ])("searches %s as a language without repository discovery", async (tech) => { + const fetch = vi + .fn() + .mockResolvedValue(json({ total_count: 0, items: [] })); + vi.stubGlobal("fetch", fetch); + await searchPullRequests({ ...filters, tech }); + expect(fetch).toHaveBeenCalledTimes(1); + const url = new URL(fetch.mock.calls[0][0]); + expect(url.pathname).toBe("/search/issues"); + expect(url.searchParams.get("q")).toContain(`language:"${tech}"`); + }); + it.each([ + ["open", "draft:false"], + ["draft", "draft:true"], + ["merged", "is:merged"], + ["closed", "is:unmerged"], + ["all", "is:pr"], + ] as const)( + "searches %s public PRs with language and organization scope", + async (status, qualifier) => { + const fetch = vi + .fn() + .mockResolvedValue(json({ total_count: 25, items: [item(1)] })); + vi.stubGlobal("fetch", fetch); + const result = await searchPullRequests({ ...filters, status }); + const query = new URL(fetch.mock.calls[0][0]).searchParams.get("q"); + expect(query).toContain("is:pr is:public"); + expect(query).toContain("org:acme"); + expect(query).toContain('language:"TypeScript"'); + expect(query).toContain(qualifier); + expect(fetch).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(true); + expect(result.pullRequests[0]).toMatchObject({ + author: "alice", + assignees: ["bob"], + labels: ["bug"], + repository: "acme/widgets", + }); + }, + ); + it("maps draft, merged, closed and deleted authors, paginates, and reports incomplete results", async () => { + vi.stubEnv("GITHUB_TOKEN", "test-token"); + const fetch = vi.fn().mockResolvedValue( + json({ + total_count: 500, + incomplete_results: true, + items: [ + item(1, { draft: true, user: null, assignees: undefined }), + item(2, { state: "closed" }), + item(3, { + state: "closed", + pull_request: { merged_at: "2026-09-01" }, + }), + ], + }), + ); + vi.stubGlobal("fetch", fetch); + const result = await searchPullRequests({ + ...filters, + status: "all", + repository: "acme/widgets", + page: 10, + sort: "comments", + }); + expect(new URL(fetch.mock.calls[0][0]).searchParams.get("page")).toBe("10"); + expect(new URL(fetch.mock.calls[0][0]).searchParams.get("q")).not.toContain( + "org:", + ); + expect(new URL(fetch.mock.calls[0][0]).searchParams.get("q")).toContain( + "repo:acme/widgets", + ); + expect(result.pullRequests.map((pr) => pr.status)).toEqual([ + "merged", + "closed", + "draft", + ]); + expect(result.pullRequests[2]).toMatchObject({ + author: "ghost", + assignees: [], + }); + expect(result.hasMore).toBe(false); + expect(result.tokenConfigured).toBe(true); + expect(result.notices).toHaveLength(2); + }); + it("discovers framework repositories inside the organization without imposing a language", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + json({ + total_count: 25, + incomplete_results: true, + items: [{ full_name: "acme/widgets", private: false }], + }), + ) + .mockResolvedValueOnce(json({ total_count: 1, items: [item(1)] })); + vi.stubGlobal("fetch", fetch); + const result = await searchPullRequests({ ...filters, tech: "React" }); + const discovery = new URL(fetch.mock.calls[0][0]).searchParams.get("q"); + expect(discovery).toContain("org:acme topic:react"); + expect(discovery).not.toContain("language:"); + expect(new URL(fetch.mock.calls[1][0]).searchParams.get("q")).toContain( + "repo:acme/widgets", + ); + expect(result.notices.join(" ")).toContain("out of 25"); + expect(result.notices.join(" ")).toContain("incomplete repository"); + }); + it.each([ + { private: true, topics: ["react"] }, + { private: false, archived: true, topics: ["react"] }, + { private: false, topics: ["vue"] }, + ])( + "does not search nonmatching or private selected repositories", + async (repo) => { + const fetch = vi + .fn() + .mockResolvedValue(json({ full_name: "acme/widgets", ...repo })); + vi.stubGlobal("fetch", fetch); + const result = await searchPullRequests({ + ...filters, + tech: "React", + repository: "acme/widgets", + }); + expect(result.pullRequests).toEqual([]); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + it("allows an exact repository outside the discovery cap and custom topics", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + json({ + full_name: "acme/widgets", + private: false, + topics: ["custom-framework"], + }), + ) + .mockResolvedValueOnce(json({ total_count: 1, items: [item(1)] })); + vi.stubGlobal("fetch", fetch); + expect( + ( + await searchPullRequests({ + ...filters, + tech: "Custom Framework", + repository: "acme/widgets", + }) + ).totalCount, + ).toBe(1); + }); + it("merges sorted repository groups before slicing a later page", async () => { + const names = Array.from( + { length: 4 }, + (_, index) => `acme/${String(index).repeat(95)}`, + ); + const fetch = vi.fn(async (input: string) => { + const url = new URL(input); + if (url.pathname.endsWith("repositories")) + return json({ + total_count: 4, + items: names.map((full_name) => ({ full_name, private: false })), + }); + expect(url.searchParams.get("q")!.length).toBeLessThanOrEqual(256); + const group = names.findIndex((name) => + url.searchParams.get("q")!.includes(name), + ); + return json({ + total_count: 100, + items: Array.from({ length: 48 }, (_, index) => + item(400 - (index * 4 + group)), + ), + }); + }); + vi.stubGlobal("fetch", fetch); + const result = await searchPullRequests({ + ...filters, + tech: "React", + sort: "comments", + page: 2, + }); + expect(result.pullRequests).toHaveLength(24); + expect(result.pullRequests[0].comments).toBe(376); + expect(result.pullRequests[23].comments).toBe(353); + expect(result.totalCount).toBe(400); + }); +}); diff --git a/tests/lib/github.test.ts b/tests/lib/github.test.ts new file mode 100644 index 0000000..5e32958 --- /dev/null +++ b/tests/lib/github.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { githubFetch } from "@/lib/github"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("GitHub request boundary", () => { + it.each([ + "/search/issues", "/search/repositories", "/repos/acme/widgets", + "/repos/acme/.github/community/profile", "/repos/acme/widgets/issues/42/comments", + "/repos/acme/widgets/issues/42/timeline", "/repos/acme/widgets/pulls/42", + ])("allows supported endpoint %s", async (path) => { + const fetch = vi.fn().mockResolvedValue(new Response("{}")); + vi.stubGlobal("fetch", fetch); + await githubFetch("https://api.github.com" + path); + expect(fetch).toHaveBeenCalledTimes(1); + }); + it.each([ + "/repos/acme/widgets/pulls/0", "/repos/acme/widgets/pulls/-1", + "/repos/acme/widgets/issues/42/comments/extra", "/repos/acme/widgets/", + "/repos/acme/", "/repos/acme/widgets/issues/0/timeline", + "/repos/acme/widgets!/community/profile", "/repos/acme!/widgets", + "/repos//widgets", "/repos/acme/widgets//community/profile", + ])("rejects invalid repository endpoint %s", async (path) => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + await expect(githubFetch("https://api.github.com" + path)).rejects.toThrow("Unsupported GitHub API endpoint."); + expect(fetch).not.toHaveBeenCalled(); + }); + it.each([ + "https://api.github.com/user/emails", + "https://api.github.com/repos/acme/widgets/contents/.env", + "https://api.github.com//attacker.example/path", + "https://api.github.com/repos/acme/widgets/issues/1/../../../../user", + "https://api.github.com/repos/acme/widgets%2F..%2Fprivate", + ])("rejects unintended GitHub paths %s", async (url) => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + await expect(githubFetch(url, "test-token")).rejects.toThrow("Unsupported GitHub API endpoint."); + expect(fetch).not.toHaveBeenCalled(); + }); + it("keeps malicious-looking filter text within encoded query values", async () => { + const fetch = vi.fn().mockResolvedValue(new Response("{}")); + vi.stubGlobal("fetch", fetch); + const query = 'is:pr https://attacker.example/a?x=1&y=2 #fragment'; + const url = new URL("https://api.github.com/search/issues"); + url.searchParams.set("q", query); + await githubFetch(url.toString()); + const destination = new URL(fetch.mock.calls[0][0]); + expect(destination.origin).toBe("https://api.github.com"); + expect(destination.pathname).toBe("/search/issues"); + expect([...destination.searchParams]).toEqual([["q", query]]); + expect(destination.hash).toBe(""); + }); + it.each([ + "http://api.github.com/repos/acme/widgets", + "https://api.github.com.attacker.example/repos/acme/widgets", + "https://api.github.com@attacker.example/repos/acme/widgets", + "https://127.0.0.1/admin", + "https://api.github.com:8443/repos/acme/widgets", + "https://user:password@api.github.com/repos/acme/widgets", + "file:///etc/passwd", + ])( + "rejects unsafe destination %s before sending credentials", + async (url) => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + await expect(githubFetch(url, "test-token")).rejects.toThrow( + "Only the GitHub HTTPS API is allowed.", + ); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + it("uses the validated GitHub origin and refuses redirects", async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ ok: true }))); + vi.stubGlobal("fetch", fetch); + expect( + ( + await githubFetch( + "https://api.github.com/search/issues?q=is%3Apr", + "test-token", + ) + ).data, + ).toEqual({ ok: true }); + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/search/issues?q=is%3Apr", + expect.objectContaining({ + redirect: "error", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + }); +}); From deabe495a14072863285120413b0aead5c9b1c29 Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Sun, 13 Sep 2026 21:23:31 +0530 Subject: [PATCH 02/10] feat: reconstruct organization issue search dashboard with auto-suggestions Signed-off-by: Arnab Nandy --- doc/architecture.md | 73 +-- src/app/api/organizations/issues/route.ts | 42 ++ src/app/api/pull-requests/details/route.ts | 29 -- src/app/api/pull-requests/enrichment/route.ts | 52 -- src/app/api/pull-requests/route.ts | 23 - .../api/suggestions/organizations/route.ts | 28 ++ src/app/api/suggestions/repositories/route.ts | 36 ++ src/app/api/suggestions/technologies/route.ts | 10 + src/app/organizations/page.tsx | 23 + src/app/pull-requests/page.tsx | 24 +- src/components/dashboard-navigation.tsx | 20 +- src/features/organizations/client.ts | 29 ++ .../components/autocomplete-input.tsx | 237 +++++++++ .../components/organization-dashboard.tsx | 458 ++++++++++++++++++ .../components/organization-issue-card.tsx | 86 ++++ .../data/languages.json | 0 src/features/organizations/data/popular.ts | 51 ++ src/features/organizations/filters.ts | 102 ++++ src/features/organizations/server/search.ts | 294 +++++++++++ .../organizations/server/suggestions.ts | 153 ++++++ src/features/organizations/types.ts | 54 +++ src/features/pull-requests/client.ts | 27 -- .../components/pull-request-card.tsx | 390 --------------- .../components/pull-request-dashboard.tsx | 372 -------------- src/features/pull-requests/data/README.md | 13 - src/features/pull-requests/filters.ts | 58 --- src/features/pull-requests/scoring.ts | 203 -------- src/features/pull-requests/server/api.ts | 33 -- .../pull-requests/server/enrichment.ts | 255 ---------- src/features/pull-requests/server/search.ts | 254 ---------- src/features/pull-requests/types.ts | 71 --- src/lib/github.ts | 6 +- tests/features/organizations/api.test.ts | 88 ++++ .../features/organizations/dashboard.test.tsx | 167 +++++++ tests/features/organizations/filters.test.ts | 97 ++++ tests/features/organizations/search.test.ts | 154 ++++++ .../organizations/suggestions.test.ts | 88 ++++ tests/features/pull-requests/api.test.ts | 136 ------ .../features/pull-requests/dashboard.test.tsx | 355 -------------- .../features/pull-requests/enrichment.test.ts | 304 ------------ tests/features/pull-requests/scoring.test.ts | 143 ------ tests/features/pull-requests/search.test.ts | 264 ---------- tests/lib/github.test.ts | 2 +- 43 files changed, 2237 insertions(+), 3067 deletions(-) create mode 100644 src/app/api/organizations/issues/route.ts delete mode 100644 src/app/api/pull-requests/details/route.ts delete mode 100644 src/app/api/pull-requests/enrichment/route.ts delete mode 100644 src/app/api/pull-requests/route.ts create mode 100644 src/app/api/suggestions/organizations/route.ts create mode 100644 src/app/api/suggestions/repositories/route.ts create mode 100644 src/app/api/suggestions/technologies/route.ts create mode 100644 src/app/organizations/page.tsx create mode 100644 src/features/organizations/client.ts create mode 100644 src/features/organizations/components/autocomplete-input.tsx create mode 100644 src/features/organizations/components/organization-dashboard.tsx create mode 100644 src/features/organizations/components/organization-issue-card.tsx rename src/features/{pull-requests => organizations}/data/languages.json (100%) create mode 100644 src/features/organizations/data/popular.ts create mode 100644 src/features/organizations/filters.ts create mode 100644 src/features/organizations/server/search.ts create mode 100644 src/features/organizations/server/suggestions.ts create mode 100644 src/features/organizations/types.ts delete mode 100644 src/features/pull-requests/client.ts delete mode 100644 src/features/pull-requests/components/pull-request-card.tsx delete mode 100644 src/features/pull-requests/components/pull-request-dashboard.tsx delete mode 100644 src/features/pull-requests/data/README.md delete mode 100644 src/features/pull-requests/filters.ts delete mode 100644 src/features/pull-requests/scoring.ts delete mode 100644 src/features/pull-requests/server/api.ts delete mode 100644 src/features/pull-requests/server/enrichment.ts delete mode 100644 src/features/pull-requests/server/search.ts delete mode 100644 src/features/pull-requests/types.ts create mode 100644 tests/features/organizations/api.test.ts create mode 100644 tests/features/organizations/dashboard.test.tsx create mode 100644 tests/features/organizations/filters.test.ts create mode 100644 tests/features/organizations/search.test.ts create mode 100644 tests/features/organizations/suggestions.test.ts delete mode 100644 tests/features/pull-requests/api.test.ts delete mode 100644 tests/features/pull-requests/dashboard.test.tsx delete mode 100644 tests/features/pull-requests/enrichment.test.ts delete mode 100644 tests/features/pull-requests/scoring.test.ts delete mode 100644 tests/features/pull-requests/search.test.ts diff --git a/doc/architecture.md b/doc/architecture.md index e93a72e..315a7a2 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -64,59 +64,26 @@ flowchart LR Drizzle <--> Turso ``` -## Pull request discovery - -`/pull-requests` provides a separate public PR dashboard with organization, -technology, optional repository, status, and sort filters stored in the URL. -`GET /api/pull-requests` returns basic results before enrichment. An exact -repository replaces the organization qualifier, since combining GitHub scope -qualifiers can broaden results. Open excludes drafts; closed excludes merged PRs. - -Languages use a bundled GitHub Linguist name/alias catalog and repository language -qualifiers. Frameworks use repository topics -without imposing an additional language. Discovery covers up to 20 recently -updated matching repositories within the organization, with visible coverage -notices. An exact repository is checked independently of that cap. Repository -groups are merged in sort order before pagination, with at most three search -requests running concurrently. Searches expose up to ten pages of 24 results. - -`POST /api/pull-requests/enrichment` accepts up to 24 PR references. It batches -review information through GraphQL and enriches up to 12 unique repositories, -three at a time. Shared repository services preserve the issue board's two-hour -metadata cache and six-hour responsiveness and documentation caches. Optional -failures remain Unknown; private repository and PR details are not returned. -All optional enrichment is skipped without the server's `GITHUB_TOKEN` to protect -the shared unauthenticated quota. This does not require visitor login. Linked issue -counts cover only public nodes in the first ten links; token-authorized connection -totals are never exposed. The shared REST helper permits only the GitHub HTTPS API -origin and refuses redirects. - -`GET /api/pull-requests/details` loads change statistics and mergeability when a -card expands. Basic results remain usable while insights load. Changing filters -or navigating away aborts stale client requests. Search, enrichment, and detail -requests have separate application rate-limit buckets. No new tables are needed. - -Performance should be measured separately for initial search and enrichment, -with warm and cold caches and different numbers of unique repositories. No -production latency target has been established. Saved PR searches and notifications -are deferred. - -Active PR cards include an explainable readiness score: non-draft state (20), -review decision (30), CI checks (25), absence of merge conflicts (15), and review -size (10). Approved reviews receive 30 points, required reviews 10, and changes -requested 0. Passing checks receive 25, pending/expected checks 5, and failures or -errors 0. Changes up to 200 lines and 5 files receive 10 size points; up to 1,000 -lines and 20 files receive 5; larger changes receive 0. These weights are product -heuristics for review readiness and effort, not code-quality judgments. - -Drafts are capped at 39. Changes requested, failing checks, or conflicts cap the -score at 49. Missing signals produce a minimum–maximum range with weighted signal -coverage rather than a failure penalty. Complete scores of 80 or more are labeled -Ready; other unblocked complete scores need attention. Merged/closed PRs are N/A. -Change statistics and mergeability are scalar fields on the existing batched -GraphQL request, so scoring adds no HTTP requests and does not delay basic search. -Without batch details, expanding a card can refine its score using the existing -on-demand detail request. +## Organization issue discovery + +`/organizations` provides a dedicated public dashboard to search open-source +issues across GitHub organizations, filtered by technology and optional repository. +URL query parameters store the active filters (`org`, `tech`, `repository`, `status`, `sort`, `page`). + +### Auto-suggestions and dependencies + +Auto-suggestions are enabled for all three primary filter inputs: +- **Organization**: Debounced queries hit `GET /api/suggestions/organizations`, combining a curated set of popular open-source organizations with live results from GitHub's `/search/users?q=${query}+type:org` endpoint. +- **Technology**: `GET /api/suggestions/technologies` returns suggestions matching curated popular technologies and the bundled GitHub Linguist language catalog. +- **Repository (optional)**: Dependent on **Organization**. The repository input is disabled until an organization is provided. When active, `GET /api/suggestions/repositories?org=${org}&query=${query}` searches public repositories within that organization via GitHub's `/search/repositories?q=org:${org}...` endpoint. Changing the organization automatically resets any previously entered repository to maintain filter consistency. + +### Query execution and discovery + +`GET /api/organizations/issues` executes issue discovery: +- Scopes issues with `is:issue is:public archived:false`. +- If an exact repository is specified, `repo:${org}/${repository}` narrows the search to that repository. Otherwise, `org:${org}` scopes to all public repositories in the organization. +- Languages use GitHub language qualifiers (`language:"${name}"`). Framework technologies without direct language mappings discover up to 20 recently updated matching repositories via repository topics before searching issues across those repositories. +- Results return mapped issue cards with repository name, title, status, labels, comments, author, and timestamps. Concurrency is bounded to at most three concurrent GitHub search requests. ## Issue discovery diff --git a/src/app/api/organizations/issues/route.ts b/src/app/api/organizations/issues/route.ts new file mode 100644 index 0000000..68fa4ed --- /dev/null +++ b/src/app/api/organizations/issues/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { isRateLimitError } from "@/lib/github"; +import { + readOrganizationFilters, + validateOrganizationFilters, +} from "@/features/organizations/filters"; +import { searchOrganizationIssues } from "@/features/organizations/server/search"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const filters = readOrganizationFilters(searchParams); + const validationError = validateOrganizationFilters(filters); + + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }); + } + + try { + const result = await searchOrganizationIssues(filters); + return NextResponse.json(result); + } catch (error) { + if (isRateLimitError(error)) { + return NextResponse.json( + { + error: error.message, + rateLimit: true, + retryAfter: error.retryAfterSeconds, + }, + { status: 429 }, + ); + } + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Unable to search organization issues.", + }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/pull-requests/details/route.ts b/src/app/api/pull-requests/details/route.ts deleted file mode 100644 index 7511898..0000000 --- a/src/app/api/pull-requests/details/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from "next/server"; -import { REPOSITORY_PATTERN } from "@/features/pull-requests/filters"; -import { getPullRequestDetails } from "@/features/pull-requests/server/enrichment"; -import { - limitPullRequestRequest, - pullRequestApiError, -} from "@/features/pull-requests/server/api"; - -export async function GET(request: Request) { - const params = new URL(request.url).searchParams; - const repository = params.get("repository") ?? ""; - const number = Number(params.get("number")); - if ( - !REPOSITORY_PATTERN.test(repository) || - !Number.isSafeInteger(number) || - number < 1 - ) - return NextResponse.json( - { error: "Provide a valid repository and PR number." }, - { status: 400 }, - ); - const limited = limitPullRequestRequest(request, "details"); - if (limited) return limited; - try { - return NextResponse.json(await getPullRequestDetails(repository, number)); - } catch (error) { - return pullRequestApiError(error); - } -} diff --git a/src/app/api/pull-requests/enrichment/route.ts b/src/app/api/pull-requests/enrichment/route.ts deleted file mode 100644 index b106f9f..0000000 --- a/src/app/api/pull-requests/enrichment/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextResponse } from "next/server"; -import { REPOSITORY_PATTERN } from "@/features/pull-requests/filters"; -import { - enrichPullRequests, - type EnrichmentReference, -} from "@/features/pull-requests/server/enrichment"; -import { - limitPullRequestRequest, - pullRequestApiError, -} from "@/features/pull-requests/server/api"; - -export async function POST(request: Request) { - const limited = limitPullRequestRequest(request, "enrichment"); - if (limited) return limited; - let body: unknown; - try { - const text = await request.text(); - if (text.length > 12000) - return NextResponse.json( - { error: "Request is too large." }, - { status: 400 }, - ); - body = JSON.parse(text); - } catch { - return NextResponse.json({ error: "Invalid JSON." }, { status: 400 }); - } - if ( - !Array.isArray(body) || - body.length < 1 || - body.length > 24 || - !body.every( - (reference) => - reference && - typeof reference.id === "string" && - /^[a-zA-Z0-9_=-]{1,200}$/.test(reference.id) && - typeof reference.repository === "string" && - REPOSITORY_PATTERN.test(reference.repository), - ) - ) { - return NextResponse.json( - { error: "Provide 1–24 valid pull request references." }, - { status: 400 }, - ); - } - try { - return NextResponse.json( - await enrichPullRequests(body as EnrichmentReference[]), - ); - } catch (error) { - return pullRequestApiError(error); - } -} diff --git a/src/app/api/pull-requests/route.ts b/src/app/api/pull-requests/route.ts deleted file mode 100644 index 33b3af0..0000000 --- a/src/app/api/pull-requests/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextResponse } from "next/server"; -import { - readPullRequestFilters, - validatePullRequestFilters, -} from "@/features/pull-requests/filters"; -import { searchPullRequests } from "@/features/pull-requests/server/search"; -import { - limitPullRequestRequest, - pullRequestApiError, -} from "@/features/pull-requests/server/api"; - -export async function GET(request: Request) { - const filters = readPullRequestFilters(new URL(request.url).searchParams); - const error = validatePullRequestFilters(filters); - if (error) return NextResponse.json({ error }, { status: 400 }); - const limited = limitPullRequestRequest(request, "search"); - if (limited) return limited; - try { - return NextResponse.json(await searchPullRequests(filters)); - } catch (error) { - return pullRequestApiError(error); - } -} diff --git a/src/app/api/suggestions/organizations/route.ts b/src/app/api/suggestions/organizations/route.ts new file mode 100644 index 0000000..cfcabea --- /dev/null +++ b/src/app/api/suggestions/organizations/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { isRateLimitError } from "@/lib/github"; +import { getOrganizationSuggestions } from "@/features/organizations/server/suggestions"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const query = searchParams.get("query") ?? ""; + + try { + const organizations = await getOrganizationSuggestions(query); + return NextResponse.json({ organizations }); + } catch (error) { + if (isRateLimitError(error)) { + return NextResponse.json( + { + error: error.message, + rateLimit: true, + retryAfter: error.retryAfterSeconds, + }, + { status: 429 }, + ); + } + return NextResponse.json( + { error: "Failed to fetch organization suggestions." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/suggestions/repositories/route.ts b/src/app/api/suggestions/repositories/route.ts new file mode 100644 index 0000000..59cb09a --- /dev/null +++ b/src/app/api/suggestions/repositories/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { isRateLimitError } from "@/lib/github"; +import { getRepositorySuggestions } from "@/features/organizations/server/suggestions"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const org = searchParams.get("org") ?? ""; + const query = searchParams.get("query") ?? ""; + + if (!org.trim()) { + return NextResponse.json( + { error: "Organization parameter is required." }, + { status: 400 }, + ); + } + + try { + const repositories = await getRepositorySuggestions(org, query); + return NextResponse.json({ repositories }); + } catch (error) { + if (isRateLimitError(error)) { + return NextResponse.json( + { + error: error.message, + rateLimit: true, + retryAfter: error.retryAfterSeconds, + }, + { status: 429 }, + ); + } + return NextResponse.json( + { error: "Failed to fetch repository suggestions." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/suggestions/technologies/route.ts b/src/app/api/suggestions/technologies/route.ts new file mode 100644 index 0000000..af14533 --- /dev/null +++ b/src/app/api/suggestions/technologies/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { getTechnologySuggestions } from "@/features/organizations/server/suggestions"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const query = searchParams.get("query") ?? ""; + + const technologies = getTechnologySuggestions(query); + return NextResponse.json({ technologies }); +} diff --git a/src/app/organizations/page.tsx b/src/app/organizations/page.tsx new file mode 100644 index 0000000..0d176a4 --- /dev/null +++ b/src/app/organizations/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { OrganizationDashboard } from "@/features/organizations/components/organization-dashboard"; + +export const metadata: Metadata = { + title: "Issues by Organization | OpenIssue.dev", + description: + "Search public GitHub issues across organizations by technology and repository, with auto-suggestions.", +}; + +export default function OrganizationsPage() { + return ( + + Loading organization dashboard… + + } + > + + + ); +} diff --git a/src/app/pull-requests/page.tsx b/src/app/pull-requests/page.tsx index 6b777f9..5506741 100644 --- a/src/app/pull-requests/page.tsx +++ b/src/app/pull-requests/page.tsx @@ -1,23 +1,5 @@ -import { Suspense } from "react"; -import type { Metadata } from "next"; -import { PullRequestDashboard } from "@/features/pull-requests/components/pull-request-dashboard"; +import { redirect } from "next/navigation"; -export const metadata: Metadata = { - title: "Pull Request Finder | OpenIssue.dev", - description: - "Search public GitHub pull requests by organization and technology, with repository insights and review status.", -}; - -export default function PullRequestsPage() { - return ( - - Loading pull request dashboard… - - } - > - - - ); +export default function PullRequestsRedirect() { + redirect("/organizations"); } diff --git a/src/components/dashboard-navigation.tsx b/src/components/dashboard-navigation.tsx index b93c267..0e3245b 100644 --- a/src/components/dashboard-navigation.tsx +++ b/src/components/dashboard-navigation.tsx @@ -1,31 +1,33 @@ import Link from "next/link"; -import { CircleDot, GitPullRequest } from "lucide-react"; +import { Building2, CircleDot } from "lucide-react"; import { cn } from "@/lib/utils"; export function DashboardNavigation({ current, -}: Readonly<{ current: "issues" | "pull-requests" }>) { +}: Readonly<{ current: "issues" | "organizations" | "pull-requests" }>) { + const activeTab = current === "pull-requests" ? "organizations" : current; + return (