From f9a15c4554c64d081a4477827fe445fe150504e3 Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Sun, 28 Jun 2026 00:10:14 +0530 Subject: [PATCH 1/2] Fix issue list ranking after load more --- .../issues/components/issue-finder.tsx | 5 +- src/features/issues/lib/ranking.ts | 23 ++++++++ src/features/issues/server/github-search.ts | 9 +-- tests/features/issues/lib/ranking.test.ts | 59 +++++++++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 src/features/issues/lib/ranking.ts create mode 100644 tests/features/issues/lib/ranking.test.ts diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index b6794e0..738dd61 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -30,6 +30,7 @@ import { TECH_EXAMPLES, } from "@/features/issues/data/search-options"; import { compactNumber } from "@/features/issues/lib/format"; +import { mergeRankedIssues, rankIssues } from "@/features/issues/lib/ranking"; import type { SearchResponse, Issue } from "@/features/issues/types/search"; export function IssueFinder() { @@ -94,7 +95,7 @@ export function IssueFinder() { } setData(payload); - setIssues(payload.issues); + setIssues(rankIssues(payload.issues)); } catch (searchError) { setError( searchError instanceof Error @@ -132,7 +133,7 @@ export function IssueFinder() { throw new Error(payload.error ?? "Failed to load more issues."); } - setIssues((prev) => [...prev, ...payload.issues]); + setIssues((prev) => mergeRankedIssues(prev, payload.issues)); setPage(nextPage); setData(payload); } catch (searchError) { diff --git a/src/features/issues/lib/ranking.ts b/src/features/issues/lib/ranking.ts new file mode 100644 index 0000000..b694817 --- /dev/null +++ b/src/features/issues/lib/ranking.ts @@ -0,0 +1,23 @@ +import type { Issue } from "@/features/issues/types/search"; + +export function rankIssues(issues: Issue[]) { + return [...issues].sort((a, b) => { + const scoreDifference = b.qualityScore - a.qualityScore; + + if (scoreDifference !== 0) { + return scoreDifference; + } + + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); +} + +export function mergeRankedIssues(existingIssues: Issue[], incomingIssues: Issue[]) { + const issueMap = new Map(); + + for (const issue of [...existingIssues, ...incomingIssues]) { + issueMap.set(issue.id, issue); + } + + return rankIssues(Array.from(issueMap.values())); +} diff --git a/src/features/issues/server/github-search.ts b/src/features/issues/server/github-search.ts index 4c204db..a6defb9 100644 --- a/src/features/issues/server/github-search.ts +++ b/src/features/issues/server/github-search.ts @@ -4,6 +4,7 @@ import { LINKED_PR_FILTERS, LANGUAGE_ALIASES, } from "@/features/issues/data/search-options"; +import { rankIssues } from "@/features/issues/lib/ranking"; import type { GitHubIssue, GitHubRepo, @@ -239,8 +240,8 @@ export async function searchGitHubIssues({ const issueCommentsMap = new Map(commentEntries); const linkedPrCountMap = new Map(linkedPrEntries); const repos = new Map(repoEntries); - const issues = search.data.items - .map((issue) => { + const issues = rankIssues( + search.data.items.map((issue) => { const repoName = getRepoFullName(issue.repository_url); const repo = repos.get(repoName); const comments = issueCommentsMap.get(issue.html_url) ?? []; @@ -267,8 +268,8 @@ export async function searchGitHubIssues({ helpStatus, qualityScore: scoreIssue(issue, repo, helpStatus), }; - }) - .sort((a, b) => b.qualityScore - a.qualityScore); + }), + ); return { query, diff --git a/tests/features/issues/lib/ranking.test.ts b/tests/features/issues/lib/ranking.test.ts new file mode 100644 index 0000000..97035ce --- /dev/null +++ b/tests/features/issues/lib/ranking.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { mergeRankedIssues, rankIssues } from "@/features/issues/lib/ranking"; +import type { Issue } from "@/features/issues/types/search"; + +function issue(overrides: Partial): Issue { + return { + id: "https://github.com/acme/widgets/issues/1", + title: "Improve widgets", + url: "https://github.com/acme/widgets/issues/1", + repo: "acme/widgets", + repoUrl: "https://github.com/acme/widgets", + stars: 100, + comments: 0, + labels: ["help wanted"], + updatedAt: "2026-06-20T10:00:00.000Z", + assigned: false, + linkedPrCount: 0, + qualityScore: 50, + helpStatus: "open", + ...overrides, + }; +} + +describe("issue ranking", () => { + it("sorts issues by quality score and then recency", () => { + const rankedIssues = rankIssues([ + issue({ id: "low", qualityScore: 40 }), + issue({ id: "older-high", qualityScore: 90, updatedAt: "2026-06-18T10:00:00.000Z" }), + issue({ id: "newer-high", qualityScore: 90, updatedAt: "2026-06-19T10:00:00.000Z" }), + ]); + + expect(rankedIssues.map((item) => item.id)).toEqual([ + "newer-high", + "older-high", + "low", + ]); + }); + + it("merges loaded pages before ranking the visible list", () => { + const mergedIssues = mergeRankedIssues( + [ + issue({ id: "first-page-low", qualityScore: 45 }), + issue({ id: "shared", qualityScore: 70 }), + ], + [ + issue({ id: "second-page-high", qualityScore: 95 }), + issue({ id: "shared", qualityScore: 75 }), + ], + ); + + expect(mergedIssues.map((item) => item.id)).toEqual([ + "second-page-high", + "shared", + "first-page-low", + ]); + expect(mergedIssues).toHaveLength(3); + expect(mergedIssues[1].qualityScore).toBe(75); + }); +}); From a1a5c306326355de360e9e2b9837f1604299918d Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Sun, 28 Jun 2026 00:20:11 +0530 Subject: [PATCH 2/2] Rank issue candidates before pagination - Fetch a larger GitHub candidate window before scoring - Return ranked page slices from the scored candidate pool - Separate ranked candidate count from total GitHub matches - Add regression coverage for ranking across candidate pages - Align Issue type with returned payload --- .../issues/components/issue-finder.tsx | 12 +- src/features/issues/server/github-search.ts | 101 +++++++++++----- src/features/issues/types/search.ts | 2 + tests/app/api/search/route.test.ts | 1 + tests/features/issues/lib/ranking.test.ts | 1 + .../issues/server/github-search.test.ts | 113 ++++++++++++++---- 6 files changed, 171 insertions(+), 59 deletions(-) diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index 738dd61..a7bd96e 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -62,7 +62,7 @@ export function IssueFinder() { const hasMore = useMemo(() => { if (!data) return false; - return issues.length < data.totalCount && data.issues.length === 24; + return issues.length < data.candidateCount && data.issues.length === 24; }, [data, issues]); async function searchIssues(event?: FormEvent) { @@ -251,7 +251,8 @@ export function IssueFinder() { - + + Ranked issues

{data.query}

- {compactNumber(data.totalCount)} GitHub matches +
+ + {compactNumber(data.candidateCount)} ranked candidates + + {compactNumber(data.totalCount)} GitHub matches +
) : ( diff --git a/src/features/issues/server/github-search.ts b/src/features/issues/server/github-search.ts index a6defb9..a2d0e59 100644 --- a/src/features/issues/server/github-search.ts +++ b/src/features/issues/server/github-search.ts @@ -10,10 +10,14 @@ import type { GitHubRepo, GitHubSearchResponse, GitHubTimelineEvent, + Issue, IssueStatus, SearchResponse, } from "@/features/issues/types/search"; +const PAGE_SIZE = 24; +const CANDIDATE_PAGE_COUNT = 5; + function normalize(value: string | null) { return (value ?? "").trim().toLowerCase(); } @@ -118,6 +122,16 @@ function scoreIssue(issue: GitHubIssue, repo?: GitHubRepo, helpStatus?: IssueSta return score; } +function dedupeIssues(issues: GitHubIssue[]) { + const issueMap = new Map(); + + for (const issue of issues) { + issueMap.set(issue.html_url, issue); + } + + return Array.from(issueMap.values()); +} + async function githubFetch(url: string, token?: string, revalidate = 60) { const response = await fetch(url, { headers: { @@ -171,17 +185,24 @@ export async function searchGitHubIssues({ const query = queryParts.join(" "); const token = process.env.GITHUB_TOKEN; - const url = new URL("https://api.github.com/search/issues"); - url.searchParams.set("q", query); - url.searchParams.set("sort", sort); - url.searchParams.set("order", "desc"); - url.searchParams.set("per_page", "24"); - url.searchParams.set("page", String(page)); - - const search = await githubFetch(url.toString(), token, 180); + const searchUrls = Array.from({ length: CANDIDATE_PAGE_COUNT }, (_, index) => { + const url = new URL("https://api.github.com/search/issues"); + url.searchParams.set("q", query); + url.searchParams.set("sort", sort); + url.searchParams.set("order", "desc"); + url.searchParams.set("per_page", String(PAGE_SIZE)); + url.searchParams.set("page", String(index + 1)); + return url.toString(); + }); + const searchResults = await Promise.all( + searchUrls.map((url) => githubFetch(url, token, 180)), + ); + const totalCount = searchResults[0]?.data.total_count ?? 0; + const rateLimitRemaining = searchResults.at(-1)?.rateLimitRemaining ?? null; + const candidateIssues = dedupeIssues(searchResults.flatMap((result) => result.data.items)); const repoNames = token ? Array.from( - new Set(search.data.items.map((item) => getRepoFullName(item.repository_url))), + new Set(candidateIssues.map((item) => getRepoFullName(item.repository_url))), ) : []; @@ -201,7 +222,7 @@ export async function searchGitHubIssues({ ); const commentEntries = await Promise.all( - search.data.items.map(async (issue) => { + candidateIssues.map(async (issue) => { if (issue.comments === 0 || !token) { return [issue.html_url, [] as Array<{ body: string }>] as const; } @@ -217,31 +238,32 @@ export async function searchGitHubIssues({ } catch { return [issue.html_url, [] as Array<{ body: string }>] as const; } - }) + }), ); - const linkedPrEntries = await Promise.all( - search.data.items.map(async (issue) => { - const repoName = getRepoFullName(issue.repository_url); + async function fetchLinkedPrCount(issue: GitHubIssue) { + if (!token) { + return [issue.html_url, null] as const; + } - try { - const timelineResult = await githubFetch( - `https://api.github.com/repos/${repoName}/issues/${issue.number}/timeline?per_page=100`, - token, - 7200, - ); - return [issue.html_url, countLinkedPullRequests(timelineResult.data)] as const; - } catch { - return [issue.html_url, null] as const; - } - }), - ); + const repoName = getRepoFullName(issue.repository_url); + + try { + const timelineResult = await githubFetch( + `https://api.github.com/repos/${repoName}/issues/${issue.number}/timeline?per_page=100`, + token, + 7200, + ); + return [issue.html_url, countLinkedPullRequests(timelineResult.data)] as const; + } catch { + return [issue.html_url, null] as const; + } + } const issueCommentsMap = new Map(commentEntries); - const linkedPrCountMap = new Map(linkedPrEntries); const repos = new Map(repoEntries); - const issues = rankIssues( - search.data.items.map((issue) => { + const rankedIssues = rankIssues( + candidateIssues.map((issue): Issue => { const repoName = getRepoFullName(issue.repository_url); const repo = repos.get(repoName); const comments = issueCommentsMap.get(issue.html_url) ?? []; @@ -264,17 +286,32 @@ export async function searchGitHubIssues({ updatedAt: issue.updated_at, createdAt: issue.created_at, assigned, - linkedPrCount: linkedPrCountMap.get(issue.html_url) ?? null, + linkedPrCount: null, helpStatus, qualityScore: scoreIssue(issue, repo, helpStatus), }; }), ); + const start = (page - 1) * PAGE_SIZE; + const selectedIssues = rankedIssues.slice(start, start + PAGE_SIZE); + const selectedIssueMap = new Map(candidateIssues.map((issue) => [issue.html_url, issue])); + const linkedPrEntries = await Promise.all( + selectedIssues + .map((issue) => selectedIssueMap.get(issue.id)) + .filter((issue): issue is GitHubIssue => Boolean(issue)) + .map(fetchLinkedPrCount), + ); + const linkedPrCountMap = new Map(linkedPrEntries); + const issues = selectedIssues.map((issue) => ({ + ...issue, + linkedPrCount: linkedPrCountMap.get(issue.id) ?? null, + })); return { query, - totalCount: search.data.total_count, - rateLimitRemaining: search.rateLimitRemaining, + totalCount, + candidateCount: rankedIssues.length, + rateLimitRemaining, tokenConfigured: Boolean(token), issues, page, diff --git a/src/features/issues/types/search.ts b/src/features/issues/types/search.ts index 4b4c4e1..1787dd1 100644 --- a/src/features/issues/types/search.ts +++ b/src/features/issues/types/search.ts @@ -15,6 +15,7 @@ export type Issue = { comments: number; labels: string[]; updatedAt: string; + createdAt: string; assigned: boolean; linkedPrCount: number | null; qualityScore: number; @@ -24,6 +25,7 @@ export type Issue = { export type SearchResponse = { query: string; totalCount: number; + candidateCount: number; rateLimitRemaining: string | null; tokenConfigured: boolean; issues: Issue[]; diff --git a/tests/app/api/search/route.test.ts b/tests/app/api/search/route.test.ts index 375f6c7..4bceae3 100644 --- a/tests/app/api/search/route.test.ts +++ b/tests/app/api/search/route.test.ts @@ -16,6 +16,7 @@ describe("GET /api/search", () => { searchGitHubIssues.mockResolvedValue({ query: "is:issue", totalCount: 0, + candidateCount: 0, rateLimitRemaining: "4999", tokenConfigured: false, issues: [], diff --git a/tests/features/issues/lib/ranking.test.ts b/tests/features/issues/lib/ranking.test.ts index 97035ce..fff3d2a 100644 --- a/tests/features/issues/lib/ranking.test.ts +++ b/tests/features/issues/lib/ranking.test.ts @@ -13,6 +13,7 @@ function issue(overrides: Partial): Issue { comments: 0, labels: ["help wanted"], updatedAt: "2026-06-20T10:00:00.000Z", + createdAt: "2026-06-19T10:00:00.000Z", assigned: false, linkedPrCount: 0, qualityScore: 50, diff --git a/tests/features/issues/server/github-search.test.ts b/tests/features/issues/server/github-search.test.ts index 4c04af3..a17a5b5 100644 --- a/tests/features/issues/server/github-search.test.ts +++ b/tests/features/issues/server/github-search.test.ts @@ -30,6 +30,15 @@ function githubIssue(overrides: Record = {}) { }; } +function searchPageResponses(items: ReturnType[], totalCount = items.length) { + return Array.from({ length: 5 }, () => + jsonResponse({ + total_count: totalCount, + items, + }), + ); +} + describe("searchGitHubIssues", () => { beforeEach(() => { vi.setSystemTime(new Date("2026-06-26T12:00:00.000Z")); @@ -47,12 +56,19 @@ describe("searchGitHubIssues", () => { }); it("adds the linked PR qualifier and maps linked PR counts", async () => { + process.env.GITHUB_TOKEN = "test-token"; const fetchMock = vi .fn() + .mockResolvedValueOnce(searchPageResponses([githubIssue()])[0]) + .mockResolvedValueOnce(searchPageResponses([githubIssue()])[1]) + .mockResolvedValueOnce(searchPageResponses([githubIssue()])[2]) + .mockResolvedValueOnce(searchPageResponses([githubIssue()])[3]) + .mockResolvedValueOnce(searchPageResponses([githubIssue()])[4]) .mockResolvedValueOnce( jsonResponse({ - total_count: 1, - items: [githubIssue()], + full_name: "acme/widgets", + html_url: "https://github.com/acme/widgets", + stargazers_count: 2500, }), ) .mockResolvedValueOnce( @@ -106,18 +122,14 @@ describe("searchGitHubIssues", () => { repo: "acme/widgets", linkedPrCount: 1, }); + expect(result.candidateCount).toBe(1); }); it("adds the negative linked PR qualifier for no-linked-PR searches", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - jsonResponse({ - total_count: 1, - items: [githubIssue()], - }), - ) - .mockResolvedValueOnce(jsonResponse([])); + const fetchMock = vi.fn(); + searchPageResponses([githubIssue()]).forEach((response) => { + fetchMock.mockResolvedValueOnce(response); + }); vi.stubGlobal("fetch", fetchMock); @@ -135,15 +147,10 @@ describe("searchGitHubIssues", () => { }); it("falls back to default sort, label, and linked PR filter for invalid inputs", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - jsonResponse({ - total_count: 1, - items: [githubIssue()], - }), - ) - .mockResolvedValueOnce(jsonResponse([])); + const fetchMock = vi.fn(); + searchPageResponses([githubIssue()]).forEach((response) => { + fetchMock.mockResolvedValueOnce(response); + }); vi.stubGlobal("fetch", fetchMock); @@ -161,16 +168,74 @@ describe("searchGitHubIssues", () => { expect(searchUrl.searchParams.get("sort")).toBe("updated"); }); - it("uses repository and comment enrichment when a GitHub token is configured", async () => { - process.env.GITHUB_TOKEN = "test-token"; + it("returns the highest scored candidates on the first result page", async () => { + const lowerScoreIssue = githubIssue({ + html_url: "https://github.com/acme/widgets/issues/1", + comments: 20, + updated_at: "2026-06-01T10:00:00.000Z", + }); + const higherScoreIssue = githubIssue({ + html_url: "https://github.com/acme/widgets/issues/2", + comments: 0, + updated_at: "2026-06-26T11:00:00.000Z", + labels: [{ name: "help wanted" }, { name: "good first issue" }], + }); const fetchMock = vi .fn() .mockResolvedValueOnce( jsonResponse({ - total_count: 1, - items: [githubIssue({ comments: 1 })], + total_count: 2, + items: [lowerScoreIssue], + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + total_count: 2, + items: [], + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + total_count: 2, + items: [higherScoreIssue], }), ) + .mockResolvedValueOnce( + jsonResponse({ + total_count: 2, + items: [], + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + total_count: 2, + items: [], + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const result = await searchGitHubIssues({ + tech: "Java", + label: "help-wanted", + sort: "updated", + linkedPr: "any", + }); + + expect(result.issues[0].id).toBe("https://github.com/acme/widgets/issues/2"); + expect(result.candidateCount).toBe(2); + expect(result.totalCount).toBe(2); + }); + + it("uses repository and comment enrichment when a GitHub token is configured", async () => { + process.env.GITHUB_TOKEN = "test-token"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(searchPageResponses([githubIssue({ comments: 1 })])[0]) + .mockResolvedValueOnce(searchPageResponses([githubIssue({ comments: 1 })])[1]) + .mockResolvedValueOnce(searchPageResponses([githubIssue({ comments: 1 })])[2]) + .mockResolvedValueOnce(searchPageResponses([githubIssue({ comments: 1 })])[3]) + .mockResolvedValueOnce(searchPageResponses([githubIssue({ comments: 1 })])[4]) .mockResolvedValueOnce( jsonResponse({ full_name: "acme/widgets",