From 3f8e3752829b2541ee06cebf06862ec9e2ff1af8 Mon Sep 17 00:00:00 2001 From: Anushka Srivastava Date: Wed, 13 Aug 2025 22:41:23 +0530 Subject: [PATCH 1/5] refactor: remove direct /search/issues calls and use central githubSearch helper --- library/githubSearch.ts | 184 ++++++++++++++++++ src/hooks/useGitHubData.ts | 82 ++++---- .../ContributorProfile/ContributorProfile.tsx | 57 ++++-- 3 files changed, 269 insertions(+), 54 deletions(-) create mode 100644 library/githubSearch.ts diff --git a/library/githubSearch.ts b/library/githubSearch.ts new file mode 100644 index 0000000..009d320 --- /dev/null +++ b/library/githubSearch.ts @@ -0,0 +1,184 @@ +/** + * Robust GitHub Search helper with date-window pagination to bypass the 1000-result cap. + * This file is framework-agnostic and can be imported from any component/hook. + */ + +export type GitHubSearchItem = { + id: number; + html_url: string; + title: string; + state: "open" | "closed"; + created_at: string; + updated_at: string; + repository_url?: string; +}; + +export type SearchMode = "issues" | "prs"; + +const GH_API = "https://api.github.com"; +const PER_PAGE = 100; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const yyyymmdd = (d: Date) => d.toISOString().slice(0, 10); + +function midpoint(a: Date, b: Date) { + const m = new Date((a.getTime() + b.getTime()) / 2); + if (yyyymmdd(m) === yyyymmdd(a)) { + const m2 = new Date(a); + m2.setDate(m2.getDate() + 1); + return m2 < b ? m2 : new Date(a.getTime() + 12 * 3600 * 1000); + } + return m; +} + +async function gh(url: string, token?: string): Promise { + let attempt = 0; + while (true) { + const resp = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (resp.ok) { + return (await resp.json()) as T; + } + + if (resp.status === 403) { + const reset = Number(resp.headers.get("X-RateLimit-Reset") || "0") * 1000; + const waitMs = Math.max(0, reset - Date.now()) + 1000; + if (waitMs > 0) await sleep(waitMs); + } else if (resp.status >= 500 && resp.status < 600) { + await sleep(300 + attempt * 300); + } else { + const text = await resp.text(); + throw new Error(`GitHub ${resp.status}: ${text}`); + } + + attempt += 1; + if (attempt >= 3) { + const text = await resp.text().catch(() => ""); + throw new Error( + `GitHub retry failed after ${attempt} attempts: ${resp.status} ${text}` + ); + } + } +} + +function buildQuery(opts: { + username: string; + mode: SearchMode; + state?: "open" | "closed" | "all"; + repo?: string; + title?: string; + start?: Date; + end?: Date; +}) { + const { username, mode, state = "all", repo, title, start, end } = opts; + const q: string[] = []; + q.push(mode === "prs" ? "type:pr" : "type:issue"); + q.push(mode === "prs" ? `author:${username}` : `involves:${username}`); + if (repo) q.push(`repo:${repo}`); + if (title) q.push(`in:title ${title}`); + if (state !== "all") q.push(`state:${state}`); + const s = start ? yyyymmdd(start) : "2008-01-01"; + const e = end ? yyyymmdd(end) : yyyymmdd(new Date()); + q.push(`created:${s}..${e}`); + return q.join("+"); +} + +async function searchCount(q: string, token?: string) { + const url = `${GH_API}/search/issues?q=${q}&per_page=1&page=1`; + const data = await gh<{ total_count: number }>(url, token); + return data.total_count; +} + +async function fetchWindow(q: string, token?: string): Promise { + const items: GitHubSearchItem[] = []; + let page = 1; + while (true) { + const url = `${GH_API}/search/issues?q=${q}&per_page=${PER_PAGE}&page=${page}`; + const data = await gh<{ items: GitHubSearchItem[] }>(url, token); + const batch = (data as any).items || []; + if (!batch.length) break; + items.push(...batch); + if (batch.length < PER_PAGE) break; + page += 1; + await sleep(120); + } + return items; +} + +/** + * Main entry: robust search for a user's Issues or PRs. + */ +export async function searchUserIssuesAndPRs(params: { + username: string; + mode: SearchMode; + token?: string; + state?: "open" | "closed" | "all"; + repo?: string; + title?: string; + start?: Date; + end?: Date; +}): Promise { + const start = params.start ?? new Date("2008-01-01"); + const end = params.end ?? new Date(); + + async function recurse(win: { start: Date; end: Date }): Promise { + const q = buildQuery({ ...params, start: win.start, end: win.end }); + const count = await searchCount(q, params.token); + + if (count === 0) return []; + if (count <= 1000) return fetchWindow(q, params.token); + + const mid = midpoint(win.start, win.end); + const left = await recurse({ start: win.start, end: mid }); + const right = await recurse({ start: new Date(mid.getTime() + 1000), end: win.end }); + return [...left, ...right]; + } + + const results = await recurse({ start, end }); + + // de-dupe and sort newest-first + const seen = new Set(); + const unique = results.filter((it) => (seen.has(it.id) ? false : (seen.add(it.id), true))); + unique.sort((a, b) => b.created_at.localeCompare(a.created_at)); + return unique; +} + +/** + * Convenience wrapper matching common caller shapes. + */ +export async function fetchUserItems(opts: { + username: string; + activeTab?: "pulls" | "issues"; + userProvidedToken?: string; + state?: "open" | "closed" | "all"; + repo?: string; + title?: string; + start?: Date; + end?: Date; +}) { + const token = + (opts.userProvidedToken && opts.userProvidedToken.trim()) || + (typeof import.meta !== "undefined" && + (import.meta as any).env && + (import.meta as any).env.VITE_GITHUB_TOKEN) || + undefined; + + const mode: SearchMode = opts.activeTab === "pulls" ? "prs" : "issues"; + + return searchUserIssuesAndPRs({ + username: opts.username, + mode, + token, + state: opts.state, + repo: opts.repo, + title: opts.title, + start: opts.start, + end: opts.end, + }); +} \ No newline at end of file diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts index a0ebe10..aac22ce 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -1,62 +1,66 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback } from "react"; +import { searchUserIssuesAndPRs } from "../../library/githubSearch"; -export const useGitHubData = (getOctokit: () => any) => { - const [issues, setIssues] = useState([]); - const [prs, setPrs] = useState([]); +type GhState = "open" | "closed" | "all"; + + +export const useGitHubData = (_getOctokit: () => any) => { + const [issues, setIssues] = useState([]); + const [prs, setPrs] = useState([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); + const [error, setError] = useState(""); const [totalIssues, setTotalIssues] = useState(0); const [totalPrs, setTotalPrs] = useState(0); const [rateLimited, setRateLimited] = useState(false); - const fetchPaginated = async (octokit: any, username: string, type: string, page = 1, per_page = 10) => { - const q = `author:${username} is:${type}`; - const response = await octokit.request('GET /search/issues', { - q, - sort: 'created', - order: 'desc', - per_page, - page, - }); - - return { - items: response.data.items, - total: response.data.total_count, - }; + // Prefer user env (Vite), but work without it too + const readToken = (): string | undefined => { + try { + // Vite exposes env under import.meta.env + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const env = (import.meta as any)?.env as Record | undefined; + return env?.VITE_GITHUB_TOKEN || undefined; + } catch { + return undefined; + } }; const fetchData = useCallback( - async (username: string, page = 1, perPage = 10) => { - - const octokit = getOctokit(); - - if (!octokit || !username || rateLimited) return; + async (username: string, page = 1, perPage = 10, state: GhState = "all") => { + if (!username || rateLimited) return; setLoading(true); - setError(''); + setError(""); try { - const [issueRes, prRes] = await Promise.all([ - fetchPaginated(octokit, username, 'issue', page, perPage), - fetchPaginated(octokit, username, 'pr', page, perPage), + const token = readToken(); + + // Fetch full result sets using robust date-window pagination (bypasses 1000 cap) + const [allIssues, allPRs] = await Promise.all([ + searchUserIssuesAndPRs({ username, mode: "issues", token, state }), + searchUserIssuesAndPRs({ username, mode: "prs", token, state }), ]); - setIssues(issueRes.items); - setPrs(prRes.items); - setTotalIssues(issueRes.total); - setTotalPrs(prRes.total); + // Save totals for pagination controls + setTotalIssues(allIssues.length); + setTotalPrs(allPRs.length); + + // Client-side slice to requested page + const startIdx = Math.max(0, (page - 1) * perPage); + const endIdx = startIdx + perPage; + setIssues(allIssues.slice(startIdx, endIdx)); + setPrs(allPRs.slice(startIdx, endIdx)); } catch (err: any) { - if (err.status === 403) { - setError('GitHub API rate limit exceeded. Please wait or use a token.'); - setRateLimited(true); // Prevent further fetches - } else { - setError(err.message || 'Failed to fetch data'); + const msg = typeof err?.message === "string" ? err.message : "Failed to fetch data"; + setError(msg); + if (msg.toLowerCase().includes("rate limit") || msg.includes("403")) { + setRateLimited(true); } } finally { setLoading(false); } }, - [getOctokit, rateLimited] + [rateLimited] ); return { @@ -69,3 +73,5 @@ export const useGitHubData = (getOctokit: () => any) => { fetchData, }; }; + +export default useGitHubData; diff --git a/src/pages/ContributorProfile/ContributorProfile.tsx b/src/pages/ContributorProfile/ContributorProfile.tsx index b4ab931..c85f5d4 100644 --- a/src/pages/ContributorProfile/ContributorProfile.tsx +++ b/src/pages/ContributorProfile/ContributorProfile.tsx @@ -1,41 +1,66 @@ import { useParams } from "react-router-dom"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; - -type PR = { - title: string; - html_url: string; - repository_url: string; -}; +import { searchUserIssuesAndPRs } from "../../../library/githubSearch"; export default function ContributorProfile() { const { username } = useParams(); const [profile, setProfile] = useState(null); - const [prs, setPRs] = useState([]); + const [prs, setPRs] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { + let canceled = false; + let toastId: string | undefined; + async function fetchData() { if (!username) return; + setLoading(true); + toastId = toast.loading("Fetching PRs…"); + try { - const userRes = await fetch(`https://api.github.com/users/${username}`); + const token = import.meta.env.VITE_GITHUB_TOKEN as string | undefined; + + // Fetch user profile (authorized if token exists) + const userRes = await fetch(`https://api.github.com/users/${username}`, { + headers: { + Accept: "application/vnd.github+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!userRes.ok) { + throw new Error(`Failed to fetch user: ${userRes.status}`); + } const userData = await userRes.json(); + if (canceled) return; setProfile(userData); - const prsRes = await fetch( - `https://api.github.com/search/issues?q=author:${username}+type:pr` - ); - const prsData = await prsRes.json(); - setPRs(prsData.items); + // Robust PR fetch: replaced with searchUserIssuesAndPRs + const prItems = await searchUserIssuesAndPRs({ + username, + mode: "prs", + state: "all", + token, + }); + if (canceled) return; + setPRs(prItems); } catch (error) { + console.error(error); toast.error("Failed to fetch user data."); } finally { + if (toastId) toast.dismiss(toastId); setLoading(false); } } fetchData(); + + return () => { + canceled = true; + if (toastId) toast.dismiss(toastId); + }; }, [username]); const handleCopyLink = () => { @@ -71,10 +96,10 @@ export default function ContributorProfile() {

Pull Requests

{prs.length > 0 ? (