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
1 change: 1 addition & 0 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export async function GET(request: Request) {
label: searchParams.get("label"),
sort: searchParams.get("sort"),
linkedPr: searchParams.get("linkedPr"),
hacktoberfest: searchParams.get("hacktoberfest"),
page,
});

Expand Down
7 changes: 7 additions & 0 deletions src/features/issues/components/issue-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export function IssueCard({ issue }: { issue: Issue }) {
{issue.repo}
</a>
<div className="flex flex-wrap items-center gap-2">
{issue.hacktoberfest ? (
<Badge className="border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-400">
{issue.hacktoberfestSource === "repo-topic"
? "Hacktoberfest repo"
: "Hacktoberfest label"}
</Badge>
) : null}
{issue.qualityScore >= 70 ? (
<Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20">
{issue.qualityScore} quality
Expand Down
26 changes: 25 additions & 1 deletion src/features/issues/components/issue-finder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { IssueCard } from "@/features/issues/components/issue-card";
import { LoadingResults } from "@/features/issues/components/loading-results";
import { Metric } from "@/features/issues/components/metric";
import {
HACKTOBERFEST_OPTIONS,
LABEL_OPTIONS,
LINKED_PR_OPTIONS,
SORT_OPTIONS,
Expand All @@ -38,6 +39,7 @@ export function IssueFinder() {
const [label, setLabel] = useState("help-wanted");
const [sort, setSort] = useState("updated");
const [linkedPr, setLinkedPr] = useState("any");
const [hacktoberfest, setHacktoberfest] = useState("any");
const [data, setData] = useState<SearchResponse | null>(null);
const [issues, setIssues] = useState<Issue[]>([]);
const [page, setPage] = useState(1);
Expand All @@ -59,6 +61,12 @@ export function IssueFinder() {
() => SORT_OPTIONS.find((item) => item.value === sort) ?? SORT_OPTIONS[0],
[sort],
);
const selectedHacktoberfest = useMemo(
() =>
HACKTOBERFEST_OPTIONS.find((item) => item.value === hacktoberfest) ??
HACKTOBERFEST_OPTIONS[0],
[hacktoberfest],
);

const hasMore = useMemo(() => {
if (!data) return false;
Expand All @@ -84,6 +92,7 @@ export function IssueFinder() {
label,
sort,
linkedPr,
hacktoberfest,
});

try {
Expand Down Expand Up @@ -122,6 +131,7 @@ export function IssueFinder() {
label,
sort,
linkedPr,
hacktoberfest,
page: String(nextPage),
});

Expand Down Expand Up @@ -176,7 +186,7 @@ export function IssueFinder() {

<form
onSubmit={searchIssues}
className="grid min-w-0 gap-3 rounded-lg border bg-card p-3 shadow-sm sm:grid-cols-2 xl:grid-cols-[1.3fr_1.1fr_1fr_1.1fr_auto]"
className="grid min-w-0 gap-3 rounded-lg border bg-card p-3 shadow-sm sm:grid-cols-2 xl:grid-cols-[1.3fr_1.1fr_1fr_1.1fr_1.1fr_auto]"
>
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
Expand Down Expand Up @@ -228,6 +238,19 @@ export function IssueFinder() {
</SelectContent>
</Select>

<Select value={hacktoberfest} onValueChange={setHacktoberfest}>
<SelectTrigger className="h-11 w-full" size="lg" aria-label="Hacktoberfest filter">
<SelectValue>{selectedHacktoberfest.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{HACKTOBERFEST_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>

<Button
type="submit"
className="h-11 w-full gap-2 sm:col-span-2 xl:col-span-1"
Expand All @@ -251,6 +274,7 @@ 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="Hacktoberfest" value={selectedHacktoberfest.label} />
<Metric label="Ranked" value={data ? compactNumber(data.candidateCount) : "-"} />
<Metric label="Matches" value={data ? compactNumber(data.totalCount) : "-"} />
<Metric
Expand Down
13 changes: 12 additions & 1 deletion src/features/issues/data/search-options.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Bug, Code2, FileText, Sparkles, Tags, Users } from "lucide-react";
import { Bug, CalendarDays, Code2, FileText, Sparkles, Tags, Users } from "lucide-react";

export const LABEL_OPTIONS = [
{ value: "help-wanted", label: "help wanted", icon: Tags },
{ value: "good-first-issue", label: "good first issue", icon: Sparkles },
{ value: "up-for-grabs", label: "up-for-grabs", icon: Users },
{ value: "first-timers-only", label: "first-timers-only", icon: Code2 },
{ value: "hacktoberfest", label: "hacktoberfest", icon: CalendarDays },
{ value: "bug", label: "bug", icon: Bug },
{ value: "documentation", label: "documentation", icon: FileText },
];
Expand All @@ -24,6 +25,7 @@ export const GITHUB_LABELS: Record<string, string> = {
"good first issue": "good first issue",
"up-for-grabs": "up-for-grabs",
"first-timers-only": "first-timers-only",
hacktoberfest: "hacktoberfest",
bug: "bug",
documentation: "documentation",
};
Expand Down Expand Up @@ -69,3 +71,12 @@ export const LINKED_PR_OPTIONS = [
export const LINKED_PR_FILTERS = new Set(
LINKED_PR_OPTIONS.map((option) => option.value),
);

export const HACKTOBERFEST_OPTIONS = [
{ value: "any", label: "All issues" },
{ value: "only", label: "Hacktoberfest ready" },
];

export const HACKTOBERFEST_FILTERS = new Set(
HACKTOBERFEST_OPTIONS.map((option) => option.value),
);
51 changes: 45 additions & 6 deletions src/features/issues/server/github-search.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
GITHUB_LABELS,
GITHUB_SORTS,
HACKTOBERFEST_FILTERS,
LINKED_PR_FILTERS,
LANGUAGE_ALIASES,
} from "@/features/issues/data/search-options";
Expand Down Expand Up @@ -102,16 +103,45 @@ function countLinkedPullRequests(events: GitHubTimelineEvent[]) {
return linkedPullRequests.size;
}

function scoreIssue(issue: GitHubIssue, repo?: GitHubRepo, helpStatus?: IssueStatus) {
function getHacktoberfestSource(issue: GitHubIssue, repo?: GitHubRepo) {
const hasRepoTopic = repo?.topics?.some(
(topic) => normalize(topic) === "hacktoberfest",
);

if (hasRepoTopic) {
return "repo-topic" as const;
}

const hasIssueLabel = issue.labels.some((label) =>
normalize(label.name).includes("hacktoberfest"),
);

return hasIssueLabel ? ("issue-label" as const) : null;
}

function scoreIssue(
issue: GitHubIssue,
repo?: GitHubRepo,
helpStatus?: IssueStatus,
hacktoberfestReady = false,
) {
const ageDays =
(Date.now() - new Date(issue.updated_at).getTime()) / (1000 * 60 * 60 * 24);
const recencyScore = Math.max(0, 35 - ageDays * 1.5);
const starScore = Math.min(25, Math.log10((repo?.stargazers_count ?? 0) + 1) * 8);
const labelScore = Math.min(20, issue.labels.length * 4);
const commentScore = Math.max(0, 15 - issue.comments * 1.5);
const assignmentScore = issue.assignee || issue.assignees?.length ? 0 : 5;

let score = Math.round(recencyScore + starScore + labelScore + commentScore + assignmentScore);
const hacktoberfestScore = hacktoberfestReady ? 8 : 0;

let score = Math.round(
recencyScore +
starScore +
labelScore +
commentScore +
assignmentScore +
hacktoberfestScore,
);

if (helpStatus === "claimed") {
score = Math.max(0, score - 25);
Expand Down Expand Up @@ -158,17 +188,22 @@ export async function searchGitHubIssues({
label: rawLabel,
sort: rawSort,
linkedPr: rawLinkedPr,
hacktoberfest: rawHacktoberfest,
page = 1,
}: {
tech: string;
label: string | null;
sort: string | null;
linkedPr: string | null;
hacktoberfest?: string | null;
page?: number;
}): Promise<SearchResponse> {
const label = GITHUB_LABELS[normalize(rawLabel)] ?? "help wanted";
const sort = GITHUB_SORTS.has(rawSort ?? "") ? rawSort! : "updated";
const linkedPr = LINKED_PR_FILTERS.has(rawLinkedPr ?? "") ? rawLinkedPr! : "any";
const hacktoberfest = HACKTOBERFEST_FILTERS.has(rawHacktoberfest ?? "")
? rawHacktoberfest!
: "any";
const queryParts = [
"is:issue",
"is:open",
Expand Down Expand Up @@ -200,7 +235,8 @@ export async function searchGitHubIssues({
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
const shouldFetchRepos = Boolean(token) || hacktoberfest === "only";
const repoNames = shouldFetchRepos
? Array.from(
new Set(candidateIssues.map((item) => getRepoFullName(item.repository_url))),
)
Expand Down Expand Up @@ -273,6 +309,7 @@ export async function searchGitHubIssues({
if (assigned) {
helpStatus = "claimed";
}
const hacktoberfestSource = getHacktoberfestSource(issue, repo);

return {
id: issue.html_url,
Expand All @@ -287,10 +324,12 @@ export async function searchGitHubIssues({
createdAt: issue.created_at,
assigned,
linkedPrCount: null,
hacktoberfest: Boolean(hacktoberfestSource),
hacktoberfestSource,
helpStatus,
qualityScore: scoreIssue(issue, repo, helpStatus),
qualityScore: scoreIssue(issue, repo, helpStatus, Boolean(hacktoberfestSource)),
};
}),
}).filter((issue) => hacktoberfest !== "only" || issue.hacktoberfest),
);
const start = (page - 1) * PAGE_SIZE;
const selectedIssues = rankedIssues.slice(start, start + PAGE_SIZE);
Expand Down
3 changes: 3 additions & 0 deletions src/features/issues/types/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type Issue = {
createdAt: string;
assigned: boolean;
linkedPrCount: number | null;
hacktoberfest: boolean;
hacktoberfestSource: "repo-topic" | "issue-label" | null;
qualityScore: number;
helpStatus?: IssueStatus;
};
Expand Down Expand Up @@ -61,6 +63,7 @@ export type GitHubRepo = {
html_url: string;
stargazers_count: number;
archived: boolean;
topics?: string[];
};

export type GitHubTimelineEvent = {
Expand Down
3 changes: 2 additions & 1 deletion tests/app/api/search/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe("GET /api/search", () => {

const response = await GET(
new Request(
"http://localhost/api/search?tech=React&label=good-first-issue&sort=created&linkedPr=yes",
"http://localhost/api/search?tech=React&label=good-first-issue&sort=created&linkedPr=yes&hacktoberfest=only",
),
);

Expand All @@ -50,6 +50,7 @@ describe("GET /api/search", () => {
label: "good-first-issue",
sort: "created",
linkedPr: "yes",
hacktoberfest: "only",
page: 1,
});
});
Expand Down
2 changes: 2 additions & 0 deletions tests/features/issues/lib/ranking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ function issue(overrides: Partial<Issue>): Issue {
createdAt: "2026-06-19T10:00:00.000Z",
assigned: false,
linkedPrCount: 0,
hacktoberfest: false,
hacktoberfestSource: null,
qualityScore: 50,
helpStatus: "open",
...overrides,
Expand Down
82 changes: 82 additions & 0 deletions tests/features/issues/server/github-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,88 @@ describe("searchGitHubIssues", () => {
expect(searchUrl.searchParams.get("sort")).toBe("updated");
});

it("filters Hacktoberfest-ready issues by repo topic or issue label", async () => {
const topicIssue = githubIssue({
html_url: "https://github.com/acme/hacktober/issues/1",
repository_url: "https://api.github.com/repos/acme/hacktober",
labels: [{ name: "help wanted" }],
});
const labelIssue = githubIssue({
html_url: "https://github.com/acme/labeled/issues/2",
repository_url: "https://api.github.com/repos/acme/labeled",
labels: [{ name: "help wanted" }, { name: "Hacktoberfest" }],
});
const plainIssue = githubIssue({
html_url: "https://github.com/acme/plain/issues/3",
repository_url: "https://api.github.com/repos/acme/plain",
labels: [{ name: "help wanted" }],
});
const fetchMock = vi.fn();
searchPageResponses([topicIssue, labelIssue, plainIssue], 3).forEach((response) => {
fetchMock.mockResolvedValueOnce(response);
});
fetchMock
.mockResolvedValueOnce(
jsonResponse({
full_name: "acme/hacktober",
html_url: "https://github.com/acme/hacktober",
stargazers_count: 100,
topics: ["hacktoberfest"],
}),
)
.mockResolvedValueOnce(
jsonResponse({
full_name: "acme/labeled",
html_url: "https://github.com/acme/labeled",
stargazers_count: 100,
topics: [],
}),
)
.mockResolvedValueOnce(
jsonResponse({
full_name: "acme/plain",
html_url: "https://github.com/acme/plain",
stargazers_count: 100,
topics: [],
}),
);

vi.stubGlobal("fetch", fetchMock);

const result = await searchGitHubIssues({
tech: "Java",
label: "help-wanted",
sort: "updated",
linkedPr: "any",
hacktoberfest: "only",
});

const searchUrl = new URL(fetchMock.mock.calls[0][0] as string);
expect(searchUrl.searchParams.get("q")).toBe(
'is:issue is:open archived:false language:Java label:"help wanted"',
);
expect(result.candidateCount).toBe(2);
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "https://github.com/acme/hacktober/issues/1",
hacktoberfest: true,
hacktoberfestSource: "repo-topic",
}),
expect.objectContaining({
id: "https://github.com/acme/labeled/issues/2",
hacktoberfest: true,
hacktoberfestSource: "issue-label",
}),
]),
);
expect(result.issues).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "https://github.com/acme/plain/issues/3" }),
]),
);
});

it("returns the highest scored candidates on the first result page", async () => {
const lowerScoreIssue = githubIssue({
html_url: "https://github.com/acme/widgets/issues/1",
Expand Down