diff --git a/library/githubSearch.ts b/library/githubSearch.ts new file mode 100644 index 0000000..dfccd7a --- /dev/null +++ b/library/githubSearch.ts @@ -0,0 +1,198 @@ +/** + * 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; + pull_request?: { + merged_at: string | null; + }; +}; + +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); + if (m2 <= b) return m2; + const halfDay = new Date(a.getTime() + 12 * 3600 * 1000); + return halfDay < b ? halfDay : b; + } + return m; +} + +async function gh(url: string, token?: string, signal?: AbortSignal): 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", + }, + signal, + }); + + if (signal?.aborted) { + throw new Error("Request aborted"); + } + + 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.replace(/"/g, '\\"')}"`); + if (state !== "all") q.push(`is:${state}`); + const s = start ? yyyymmdd(start) : "2008-01-01"; + const e = end ? yyyymmdd(end) : yyyymmdd(new Date()); + q.push(`created:${s}..${e}`); + // NOTE: we join with '+' for GitHub search; each token may contain quotes; the URL layer encodes the string. + return q.join("+"); +} + +async function searchCount(q: string, token?: string, signal?: AbortSignal) { + const url = `${GH_API}/search/issues?q=${q}&per_page=1&page=1`; + const data = await gh<{ total_count: number }>(url, token, signal); + return data.total_count; +} + +async function fetchWindow(q: string, token?: string, signal?: AbortSignal): 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, signal); + const batch = data.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; + signal?: AbortSignal; +}): 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, params.signal); + + if (count === 0) return []; + if (count <= 1000) return fetchWindow(q, params.token, params.signal); + + 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() + 1), 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; + signal?: AbortSignal; +}) { + 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, + signal: opts.signal, + }); +} \ No newline at end of file diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts index a0ebe10..1b05078 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -1,62 +1,105 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback } from "react"; +import { searchUserIssuesAndPRs, GitHubSearchItem } from "../../library/githubSearch"; -export const useGitHubData = (getOctokit: () => any) => { - const [issues, setIssues] = useState([]); - const [prs, setPrs] = useState([]); +type GhState = "open" | "closed" | "all"; + + +export const useGitHubData = () => { + 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(); + async ( + username: string, + page = 1, + perPage = 10, + state: GhState = "all", + signal?: AbortSignal + ) => { + setLoading(true); + try { + const token = readToken(); - if (!octokit || !username || rateLimited) return; + // basic param validation + if (page < 1 || perPage < 1) { + throw new Error("Invalid pagination parameters"); + } - setLoading(true); - setError(''); + // clear old error; handle username/rate-limit UX + setError(""); + if (!username) { + setLoading(false); + return; + } + if (rateLimited) { + setError("Rate limited. Please try again later."); + setLoading(false); + return; + } - try { - const [issueRes, prRes] = await Promise.all([ - fetchPaginated(octokit, username, 'issue', page, perPage), - fetchPaginated(octokit, username, 'pr', page, perPage), - ]); + // Abortable requests to avoid setState-on-unmounted + const internalCtrl = signal ? null : new AbortController(); + const activeSignal = signal ?? internalCtrl!.signal; + + try { + // Fetch full result sets using robust date-window pagination (bypasses 1000 cap) + const [allIssues, allPRs] = await Promise.all([ + searchUserIssuesAndPRs({ username, mode: "issues", token, state, signal: activeSignal }), + searchUserIssuesAndPRs({ username, mode: "prs", token, state, signal: activeSignal }), + ]); + + // Save totals for pagination controls + setTotalIssues(allIssues.length); + setTotalPrs(allPRs.length); - setIssues(issueRes.items); - setPrs(prRes.items); - setTotalIssues(issueRes.total); - setTotalPrs(prRes.total); - } 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'); + // 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)); + + // clear rate-limit if we succeeded + if (rateLimited) setRateLimited(false); + } finally { + // Only abort if we created the controller + if (internalCtrl) internalCtrl.abort(); + } + } catch (err: unknown) { + let msg = "Failed to fetch data"; + if (err && typeof err === "object" && "message" in err && typeof (err as any).message === "string") { + msg = (err as any).message as string; + } + setError(msg); + // GitHub returns 403 with specific rate limit message + if ( + msg.toLowerCase().includes("rate limit") || + msg.includes("API rate limit exceeded") || + (msg.includes("403") && msg.toLowerCase().includes("limit")) + ) { + setRateLimited(true); } } finally { setLoading(false); } }, - [getOctokit, rateLimited] + [rateLimited] ); return { @@ -69,3 +112,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..d077405 100644 --- a/src/pages/ContributorProfile/ContributorProfile.tsx +++ b/src/pages/ContributorProfile/ContributorProfile.tsx @@ -1,54 +1,138 @@ import { useParams } from "react-router-dom"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; +import { searchUserIssuesAndPRs, GitHubSearchItem } from "../../../library/githubSearch"; -type PR = { - title: string; - html_url: string; - repository_url: string; +// Minimal shape from the GitHub Users API we actually render +type GitHubUser = { + login: string; + avatar_url: string; + bio?: string | null; }; export default function ContributorProfile() { const { username } = useParams(); - const [profile, setProfile] = useState(null); - const [prs, setPRs] = useState([]); + const [profile, setProfile] = useState(null); + const [prs, setPRs] = useState([]); const [loading, setLoading] = useState(true); + const [errorMsg, setErrorMsg] = useState(null); useEffect(() => { + const controller = new AbortController(); + let canceled = false; + let toastId: string | undefined; + async function fetchData() { - if (!username) return; + if (!username) { + setLoading(false); + setErrorMsg("No username provided."); + return; + } + + setLoading(true); + setErrorMsg(null); + toastId = toast.loading("Fetching PRs…"); try { - const userRes = await fetch(`https://api.github.com/users/${username}`); + const isDev = import.meta.env.DEV; + const token = isDev ? (import.meta.env.VITE_GITHUB_TOKEN as string | undefined) : 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", + }, + signal: controller.signal, + }); + if (controller.signal.aborted) return; + if (!userRes.ok) { + if (userRes.status === 404) { + setProfile(null); + throw new Error("User not found"); + } + if (userRes.status === 403) { + const rem = userRes.headers.get("x-ratelimit-remaining"); + const msg = + rem === "0" + ? "GitHub API rate limit exceeded. Please try again later." + : "Access forbidden. If in development, set VITE_GITHUB_TOKEN."; + throw new Error(msg); + } + throw new Error(`Failed to fetch user: ${userRes.status}`); + } const userData = await userRes.json(); - setProfile(userData); + if (canceled) return; + setProfile(userData as GitHubUser); - 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, + signal: controller.signal, + }); + if (canceled) return; + setPRs([...prItems].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())); } catch (error) { - toast.error("Failed to fetch user data."); + // Ignore expected aborts (navigation/unmount) + if (canceled || (error as any)?.name === "AbortError") { + return; + } + console.error(error); + const msg = error instanceof Error ? error.message : "Failed to fetch user data."; + setErrorMsg(msg); + toast.error(msg); } finally { - setLoading(false); + if (toastId) toast.dismiss(toastId); + if (!controller.signal.aborted && !canceled) { + setLoading(false); + } } } fetchData(); + + return () => { + controller.abort(); + canceled = true; + if (toastId) { + toast.dismiss(toastId); + toastId = undefined; + } + }; }, [username]); - const handleCopyLink = () => { - navigator.clipboard.writeText(window.location.href); - toast.success("🔗 Shareable link copied to clipboard!"); + const handleCopyLink = async () => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(window.location.href); + } else { + // Fallback for older browsers + const el = document.createElement("textarea"); + el.value = window.location.href; + document.body.appendChild(el); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); + } + toast.success("🔗 Shareable link copied to clipboard!"); + } catch { + toast.error("Could not copy link"); + } }; if (loading) return
Loading...
; - if (!profile) + if (!profile) { return ( -
User not found.
+
+ {errorMsg ?? "User not found."} +
); + } return (
@@ -59,7 +143,7 @@ export default function ContributorProfile() { className="w-24 h-24 mx-auto rounded-full" />

{profile.login}

-

{profile.bio}

+

{profile.bio ?? ""}

+ {errorMsg ? ( +
+ {errorMsg} +
+ ) : null} +

Pull Requests

{prs.length > 0 ? (
    - {prs.map((pr, i) => { - const repoName = pr.repository_url.split("/").slice(-2).join("/"); + {prs.map((pr) => { + const repoName = (() => { + if (!pr.repository_url) return "unknown"; + try { + const url = new URL(pr.repository_url); + const parts = url.pathname.split("/").filter(Boolean); + return parts.slice(-2).join("/"); + } catch { + return "unknown"; + } + })(); return ( -
  • +
  • - [{repoName}] {pr.title} + {`[${repoName}] ${pr.title}`} + {pr.pull_request?.merged_at ? ( + merged + ) : pr.state === "closed" ? ( + closed + ) : null}
  • ); diff --git a/vite.config.ts b/vite.config.ts index 8b0f57b..48c8cd0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,54 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], -}) + plugins: [ + react(), + ], + + // Speedier, quieter builds + build: { + target: "es2020", + sourcemap: process.env.NODE_ENV === 'development', // source maps only in dev + cssCodeSplit: true, // keep CSS split for better caching + minify: "esbuild", + reportCompressedSize: false, // avoid gzip size computation in logs + chunkSizeWarningLimit: 1024, // raise warning threshold to 1 MB + rollupOptions: { + output: { + // Use function form to control vendor chunking (avoids Vite warning) + manualChunks(id: string) { + if (id.includes("node_modules")) { + if (id.includes("react")) return "react"; + if (id.includes("react-router")) return "router"; + return "vendor"; + } + }, + }, + }, + }, + + // Helpful when running on LAN / different ports during dev previews + server: { + host: true, + // If the error overlay is too noisy during dev, flip this to `false`. + hmr: { + overlay: true, + }, + }, + + // Ensure the preview server behaves like dev for hosting on LAN + preview: { + host: true, + }, + + resolve: { + dedupe: ["react", "react-dom"], + }, + + // Ensure fast cold starts during dev + optimizeDeps: { + include: ["react", "react-dom", "react-router-dom"], + }, +});