Skip to content
Open
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
198 changes: 198 additions & 0 deletions library/githubSearch.ts
Original file line number Diff line number Diff line change
@@ -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<T>(url: string, token?: string, signal?: AbortSignal): 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",
},
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<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, 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<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, 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<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;
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,
});
}
127 changes: 86 additions & 41 deletions src/hooks/useGitHubData.ts
Original file line number Diff line number Diff line change
@@ -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<GitHubSearchItem[]>([]);
const [prs, setPrs] = useState<GitHubSearchItem[]>([]);
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();
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 {
Expand All @@ -69,3 +112,5 @@ export const useGitHubData = (getOctokit: () => any) => {
fetchData,
};
};

export default useGitHubData;
Loading