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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/features/issues/components/issue-finder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -61,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<HTMLFormElement>) {
Expand Down Expand Up @@ -94,7 +95,7 @@ export function IssueFinder() {
}

setData(payload);
setIssues(payload.issues);
setIssues(rankIssues(payload.issues));
} catch (searchError) {
setError(
searchError instanceof Error
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -250,7 +251,8 @@ export function IssueFinder() {
<Metric label="Label" value={selectedLabel.label} />
<Metric label="Sort" value={sort === "created" ? "newest" : sort} />
<Metric label="Linked PR" value={selectedLinkedPr.label.replace("Linked PR: ", "")} />
<Metric label="Results" value={data ? compactNumber(data.totalCount) : "-"} />
<Metric label="Ranked" value={data ? compactNumber(data.candidateCount) : "-"} />
<Metric label="Matches" value={data ? compactNumber(data.totalCount) : "-"} />
<Metric
label="GitHub token"
value={data?.tokenConfigured ? "configured" : "not set"}
Expand Down Expand Up @@ -321,7 +323,12 @@ export function IssueFinder() {
<h2 className="text-xl font-semibold">Ranked issues</h2>
<p className="text-sm text-muted-foreground">{data.query}</p>
</div>
<Badge variant="secondary">{compactNumber(data.totalCount)} GitHub matches</Badge>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">
{compactNumber(data.candidateCount)} ranked candidates
</Badge>
<Badge variant="outline">{compactNumber(data.totalCount)} GitHub matches</Badge>
</div>
</div>
) : (
<Card>
Expand Down
23 changes: 23 additions & 0 deletions src/features/issues/lib/ranking.ts
Original file line number Diff line number Diff line change
@@ -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<string, Issue>();

for (const issue of [...existingIssues, ...incomingIssues]) {
issueMap.set(issue.id, issue);
}

return rankIssues(Array.from(issueMap.values()));
}
106 changes: 72 additions & 34 deletions src/features/issues/server/github-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ import {
LINKED_PR_FILTERS,
LANGUAGE_ALIASES,
} from "@/features/issues/data/search-options";
import { rankIssues } from "@/features/issues/lib/ranking";
import type {
GitHubIssue,
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();
}
Expand Down Expand Up @@ -117,6 +122,16 @@ function scoreIssue(issue: GitHubIssue, repo?: GitHubRepo, helpStatus?: IssueSta
return score;
}

function dedupeIssues(issues: GitHubIssue[]) {
const issueMap = new Map<string, GitHubIssue>();

for (const issue of issues) {
issueMap.set(issue.html_url, issue);
}

return Array.from(issueMap.values());
}

async function githubFetch<T>(url: string, token?: string, revalidate = 60) {
const response = await fetch(url, {
headers: {
Expand Down Expand Up @@ -170,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<GitHubSearchResponse>(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<GitHubSearchResponse>(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))),
)
: [];

Expand All @@ -200,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;
}
Expand All @@ -216,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<GitHubTimelineEvent[]>(
`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<GitHubTimelineEvent[]>(
`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 = 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) ?? [];
Expand All @@ -263,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),
};
})
.sort((a, b) => b.qualityScore - a.qualityScore);
}),
);
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,
Expand Down
2 changes: 2 additions & 0 deletions src/features/issues/types/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type Issue = {
comments: number;
labels: string[];
updatedAt: string;
createdAt: string;
assigned: boolean;
linkedPrCount: number | null;
qualityScore: number;
Expand All @@ -24,6 +25,7 @@ export type Issue = {
export type SearchResponse = {
query: string;
totalCount: number;
candidateCount: number;
rateLimitRemaining: string | null;
tokenConfigured: boolean;
issues: Issue[];
Expand Down
1 change: 1 addition & 0 deletions tests/app/api/search/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe("GET /api/search", () => {
searchGitHubIssues.mockResolvedValue({
query: "is:issue",
totalCount: 0,
candidateCount: 0,
rateLimitRemaining: "4999",
tokenConfigured: false,
issues: [],
Expand Down
60 changes: 60 additions & 0 deletions tests/features/issues/lib/ranking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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>): 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",
createdAt: "2026-06-19T10: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);
});
});
Loading