Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
184 changes: 184 additions & 0 deletions library/githubSearch.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Comment thread
ASR1015 marked this conversation as resolved.

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;
}
Comment thread
ASR1015 marked this conversation as resolved.

async function gh<T>(url: string, token?: string): Promise<T> {
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}`
);
}
}
}
Comment thread
ASR1015 marked this conversation as resolved.
Outdated

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<GitHubSearchItem[]> {
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;
}
Comment thread
ASR1015 marked this conversation as resolved.
Outdated

/**
* 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<GitHubSearchItem[]> {
const start = params.start ?? new Date("2008-01-01");
const end = params.end ?? new Date();

async function recurse(win: { start: Date; end: Date }): Promise<GitHubSearchItem[]> {
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];
}
Comment thread
ASR1015 marked this conversation as resolved.

const results = await recurse({ start, end });

// de-dupe and sort newest-first
const seen = new Set<number>();
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,
});
}
82 changes: 44 additions & 38 deletions src/hooks/useGitHubData.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Comment thread
ASR1015 marked this conversation as resolved.
Outdated
const [issues, setIssues] = useState<any[]>([]);
const [prs, setPrs] = useState<any[]>([]);
Comment thread
ASR1015 marked this conversation as resolved.
Outdated
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<string, string> | 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;
Comment thread
ASR1015 marked this conversation as resolved.
Outdated

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));
Comment thread
ASR1015 marked this conversation as resolved.
Outdated
} 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 {
Expand All @@ -69,3 +73,5 @@ export const useGitHubData = (getOctokit: () => any) => {
fetchData,
};
};

export default useGitHubData;
57 changes: 41 additions & 16 deletions src/pages/ContributorProfile/ContributorProfile.tsx
Original file line number Diff line number Diff line change
@@ -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<any>(null);
const [prs, setPRs] = useState<PR[]>([]);
const [prs, setPRs] = useState<any[]>([]);
Comment thread
ASR1015 marked this conversation as resolved.
Outdated
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…");

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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",
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
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 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (toastId) toast.dismiss(toastId);
setLoading(false);
}
}

fetchData();

return () => {
canceled = true;
if (toastId) toast.dismiss(toastId);
};
}, [username]);

const handleCopyLink = () => {
Expand Down Expand Up @@ -71,10 +96,10 @@ export default function ContributorProfile() {
<h3 className="text-xl font-semibold mt-6 mb-2">Pull Requests</h3>
{prs.length > 0 ? (
<ul className="list-disc ml-6 space-y-2">
{prs.map((pr, i) => {
const repoName = pr.repository_url.split("/").slice(-2).join("/");
{prs.map((pr) => {
const repoName = pr.repository_url?.split("/").slice(-2).join("/") ?? "";
return (
<li key={i}>
<li key={pr.id}>
<a
href={pr.html_url}
target="_blank"
Expand Down