Skip to content

Commit 1eff568

Browse files
committed
ux: show error message on contributor profile; guard loading on abort
1 parent 3f8e375 commit 1eff568

4 files changed

Lines changed: 196 additions & 53 deletions

File tree

library/githubSearch.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export type GitHubSearchItem = {
1111
created_at: string;
1212
updated_at: string;
1313
repository_url?: string;
14+
pull_request?: {
15+
merged_at: string | null;
16+
};
1417
};
1518

1619
export type SearchMode = "issues" | "prs";
@@ -26,12 +29,14 @@ function midpoint(a: Date, b: Date) {
2629
if (yyyymmdd(m) === yyyymmdd(a)) {
2730
const m2 = new Date(a);
2831
m2.setDate(m2.getDate() + 1);
29-
return m2 < b ? m2 : new Date(a.getTime() + 12 * 3600 * 1000);
32+
if (m2 <= b) return m2;
33+
const halfDay = new Date(a.getTime() + 12 * 3600 * 1000);
34+
return halfDay < b ? halfDay : b;
3035
}
3136
return m;
3237
}
3338

34-
async function gh<T>(url: string, token?: string): Promise<T> {
39+
async function gh<T>(url: string, token?: string, signal?: AbortSignal): Promise<T> {
3540
let attempt = 0;
3641
while (true) {
3742
const resp = await fetch(url, {
@@ -40,8 +45,13 @@ async function gh<T>(url: string, token?: string): Promise<T> {
4045
...(token ? { Authorization: `Bearer ${token}` } : {}),
4146
"X-GitHub-Api-Version": "2022-11-28",
4247
},
48+
signal,
4349
});
4450

51+
if (signal?.aborted) {
52+
throw new Error("Request aborted");
53+
}
54+
4555
if (resp.ok) {
4656
return (await resp.json()) as T;
4757
}
@@ -81,27 +91,28 @@ function buildQuery(opts: {
8191
q.push(mode === "prs" ? "type:pr" : "type:issue");
8292
q.push(mode === "prs" ? `author:${username}` : `involves:${username}`);
8393
if (repo) q.push(`repo:${repo}`);
84-
if (title) q.push(`in:title ${title}`);
85-
if (state !== "all") q.push(`state:${state}`);
94+
if (title) q.push(`in:title "${title.replace(/"/g, '\\"')}"`);
95+
if (state !== "all") q.push(`is:${state}`);
8696
const s = start ? yyyymmdd(start) : "2008-01-01";
8797
const e = end ? yyyymmdd(end) : yyyymmdd(new Date());
8898
q.push(`created:${s}..${e}`);
99+
// NOTE: we join with '+' for GitHub search; each token may contain quotes; the URL layer encodes the string.
89100
return q.join("+");
90101
}
91102

92-
async function searchCount(q: string, token?: string) {
103+
async function searchCount(q: string, token?: string, signal?: AbortSignal) {
93104
const url = `${GH_API}/search/issues?q=${q}&per_page=1&page=1`;
94-
const data = await gh<{ total_count: number }>(url, token);
105+
const data = await gh<{ total_count: number }>(url, token, signal);
95106
return data.total_count;
96107
}
97108

98-
async function fetchWindow(q: string, token?: string): Promise<GitHubSearchItem[]> {
109+
async function fetchWindow(q: string, token?: string, signal?: AbortSignal): Promise<GitHubSearchItem[]> {
99110
const items: GitHubSearchItem[] = [];
100111
let page = 1;
101112
while (true) {
102113
const url = `${GH_API}/search/issues?q=${q}&per_page=${PER_PAGE}&page=${page}`;
103-
const data = await gh<{ items: GitHubSearchItem[] }>(url, token);
104-
const batch = (data as any).items || [];
114+
const data = await gh<{ items: GitHubSearchItem[] }>(url, token, signal);
115+
const batch = data.items || [];
105116
if (!batch.length) break;
106117
items.push(...batch);
107118
if (batch.length < PER_PAGE) break;
@@ -123,20 +134,21 @@ export async function searchUserIssuesAndPRs(params: {
123134
title?: string;
124135
start?: Date;
125136
end?: Date;
137+
signal?: AbortSignal;
126138
}): Promise<GitHubSearchItem[]> {
127139
const start = params.start ?? new Date("2008-01-01");
128140
const end = params.end ?? new Date();
129141

130142
async function recurse(win: { start: Date; end: Date }): Promise<GitHubSearchItem[]> {
131143
const q = buildQuery({ ...params, start: win.start, end: win.end });
132-
const count = await searchCount(q, params.token);
144+
const count = await searchCount(q, params.token, params.signal);
133145

134146
if (count === 0) return [];
135-
if (count <= 1000) return fetchWindow(q, params.token);
147+
if (count <= 1000) return fetchWindow(q, params.token, params.signal);
136148

137149
const mid = midpoint(win.start, win.end);
138150
const left = await recurse({ start: win.start, end: mid });
139-
const right = await recurse({ start: new Date(mid.getTime() + 1000), end: win.end });
151+
const right = await recurse({ start: new Date(mid.getTime() + 1), end: win.end });
140152
return [...left, ...right];
141153
}
142154

@@ -161,6 +173,7 @@ export async function fetchUserItems(opts: {
161173
title?: string;
162174
start?: Date;
163175
end?: Date;
176+
signal?: AbortSignal;
164177
}) {
165178
const token =
166179
(opts.userProvidedToken && opts.userProvidedToken.trim()) ||
@@ -180,5 +193,6 @@ export async function fetchUserItems(opts: {
180193
title: opts.title,
181194
start: opts.start,
182195
end: opts.end,
196+
signal: opts.signal,
183197
});
184198
}

src/hooks/useGitHubData.ts

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { useState, useCallback } from "react";
2-
import { searchUserIssuesAndPRs } from "../../library/githubSearch";
2+
import { searchUserIssuesAndPRs, GitHubSearchItem } from "../../library/githubSearch";
33

44
type GhState = "open" | "closed" | "all";
55

66

7-
export const useGitHubData = (_getOctokit: () => any) => {
8-
const [issues, setIssues] = useState<any[]>([]);
9-
const [prs, setPrs] = useState<any[]>([]);
7+
export const useGitHubData = () => {
8+
const [issues, setIssues] = useState<GitHubSearchItem[]>([]);
9+
const [prs, setPrs] = useState<GitHubSearchItem[]>([]);
1010
const [loading, setLoading] = useState(false);
1111
const [error, setError] = useState("");
1212
const [totalIssues, setTotalIssues] = useState(0);
@@ -26,32 +26,66 @@ export const useGitHubData = (_getOctokit: () => any) => {
2626
};
2727

2828
const fetchData = useCallback(
29-
async (username: string, page = 1, perPage = 10, state: GhState = "all") => {
30-
if (!username || rateLimited) return;
31-
29+
async (
30+
username: string,
31+
page = 1,
32+
perPage = 10,
33+
state: GhState = "all",
34+
signal?: AbortSignal
35+
) => {
3236
setLoading(true);
33-
setError("");
34-
3537
try {
3638
const token = readToken();
3739

38-
// Fetch full result sets using robust date-window pagination (bypasses 1000 cap)
39-
const [allIssues, allPRs] = await Promise.all([
40-
searchUserIssuesAndPRs({ username, mode: "issues", token, state }),
41-
searchUserIssuesAndPRs({ username, mode: "prs", token, state }),
42-
]);
40+
// basic param validation
41+
if (page < 1 || perPage < 1) {
42+
throw new Error("Invalid pagination parameters");
43+
}
44+
45+
// clear old error; handle username/rate-limit UX
46+
setError("");
47+
if (!username) {
48+
setLoading(false);
49+
return;
50+
}
51+
if (rateLimited) {
52+
setError("Rate limited. Please try again later.");
53+
setLoading(false);
54+
return;
55+
}
4356

44-
// Save totals for pagination controls
45-
setTotalIssues(allIssues.length);
46-
setTotalPrs(allPRs.length);
57+
// Abortable requests to avoid setState-on-unmounted
58+
const internalCtrl = signal ? null : new AbortController();
59+
const activeSignal = signal ?? internalCtrl!.signal;
4760

48-
// Client-side slice to requested page
49-
const startIdx = Math.max(0, (page - 1) * perPage);
50-
const endIdx = startIdx + perPage;
51-
setIssues(allIssues.slice(startIdx, endIdx));
52-
setPrs(allPRs.slice(startIdx, endIdx));
53-
} catch (err: any) {
54-
const msg = typeof err?.message === "string" ? err.message : "Failed to fetch data";
61+
try {
62+
// Fetch full result sets using robust date-window pagination (bypasses 1000 cap)
63+
const [allIssues, allPRs] = await Promise.all([
64+
searchUserIssuesAndPRs({ username, mode: "issues", token, state, signal: activeSignal }),
65+
searchUserIssuesAndPRs({ username, mode: "prs", token, state, signal: activeSignal }),
66+
]);
67+
68+
// Save totals for pagination controls
69+
setTotalIssues(allIssues.length);
70+
setTotalPrs(allPRs.length);
71+
72+
// Client-side slice to requested page
73+
const startIdx = Math.max(0, (page - 1) * perPage);
74+
const endIdx = startIdx + perPage;
75+
setIssues(allIssues.slice(startIdx, endIdx));
76+
setPrs(allPRs.slice(startIdx, endIdx));
77+
78+
// clear rate-limit if we succeeded
79+
if (rateLimited) setRateLimited(false);
80+
} finally {
81+
// Only abort if we created the controller
82+
if (internalCtrl) internalCtrl.abort();
83+
}
84+
} catch (err: unknown) {
85+
let msg = "Failed to fetch data";
86+
if (err && typeof err === "object" && "message" in err && typeof (err as any).message === "string") {
87+
msg = (err as any).message as string;
88+
}
5589
setError(msg);
5690
if (msg.toLowerCase().includes("rate limit") || msg.includes("403")) {
5791
setRateLimited(true);

src/pages/ContributorProfile/ContributorProfile.tsx

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
11
import { useParams } from "react-router-dom";
22
import { useEffect, useState } from "react";
33
import toast from "react-hot-toast";
4-
import { searchUserIssuesAndPRs } from "../../../library/githubSearch";
4+
import { searchUserIssuesAndPRs, GitHubSearchItem } from "../../../library/githubSearch";
5+
6+
// Minimal shape from the GitHub Users API we actually render
7+
type GitHubUser = {
8+
login: string;
9+
avatar_url: string;
10+
bio?: string | null;
11+
};
512

613
export default function ContributorProfile() {
714
const { username } = useParams();
8-
const [profile, setProfile] = useState<any>(null);
9-
const [prs, setPRs] = useState<any[]>([]);
15+
const [profile, setProfile] = useState<GitHubUser | null>(null);
16+
const [prs, setPRs] = useState<GitHubSearchItem[]>([]);
1017
const [loading, setLoading] = useState(true);
18+
const [errorMsg, setErrorMsg] = useState<string | null>(null);
1119

1220
useEffect(() => {
21+
const controller = new AbortController();
1322
let canceled = false;
1423
let toastId: string | undefined;
1524

1625
async function fetchData() {
1726
if (!username) return;
1827

1928
setLoading(true);
29+
setErrorMsg(null);
2030
toastId = toast.loading("Fetching PRs…");
2131

2232
try {
@@ -29,43 +39,72 @@ export default function ContributorProfile() {
2939
...(token ? { Authorization: `Bearer ${token}` } : {}),
3040
"X-GitHub-Api-Version": "2022-11-28",
3141
},
42+
signal: controller.signal,
3243
});
44+
if (controller.signal.aborted) return;
3345
if (!userRes.ok) {
46+
if (userRes.status === 404) {
47+
setProfile(null);
48+
throw new Error("User not found");
49+
}
3450
throw new Error(`Failed to fetch user: ${userRes.status}`);
3551
}
3652
const userData = await userRes.json();
3753
if (canceled) return;
38-
setProfile(userData);
54+
setProfile(userData as GitHubUser);
3955

4056
// Robust PR fetch: replaced with searchUserIssuesAndPRs
4157
const prItems = await searchUserIssuesAndPRs({
4258
username,
4359
mode: "prs",
4460
state: "all",
4561
token,
62+
signal: controller.signal,
4663
});
4764
if (canceled) return;
48-
setPRs(prItems);
65+
setPRs([...prItems].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()));
4966
} catch (error) {
5067
console.error(error);
51-
toast.error("Failed to fetch user data.");
68+
const msg = error instanceof Error ? error.message : "Failed to fetch user data.";
69+
setErrorMsg(msg);
70+
toast.error(msg);
5271
} finally {
5372
if (toastId) toast.dismiss(toastId);
54-
setLoading(false);
73+
if (!controller.signal.aborted && !canceled) {
74+
setLoading(false);
75+
}
5576
}
5677
}
5778

5879
fetchData();
5980

6081
return () => {
82+
controller.abort();
6183
canceled = true;
62-
if (toastId) toast.dismiss(toastId);
84+
if (toastId) {
85+
toast.dismiss(toastId);
86+
toastId = undefined;
87+
}
6388
};
6489
}, [username]);
6590

66-
const handleCopyLink = () => {
67-
navigator.clipboard.writeText(window.location.href);
68-
toast.success("🔗 Shareable link copied to clipboard!");
91+
const handleCopyLink = async () => {
92+
try {
93+
if (navigator.clipboard?.writeText) {
94+
await navigator.clipboard.writeText(window.location.href);
95+
} else {
96+
// Fallback for older browsers
97+
const el = document.createElement("textarea");
98+
el.value = window.location.href;
99+
document.body.appendChild(el);
100+
el.select();
101+
document.execCommand("copy");
102+
document.body.removeChild(el);
103+
}
104+
toast.success("🔗 Shareable link copied to clipboard!");
105+
} catch {
106+
toast.error("Could not copy link");
107+
}
69108
};
70109

71110
if (loading) return <div className="text-center mt-10">Loading...</div>;
@@ -84,7 +123,7 @@ export default function ContributorProfile() {
84123
className="w-24 h-24 mx-auto rounded-full"
85124
/>
86125
<h2 className="text-2xl font-bold mt-2">{profile.login}</h2>
87-
<p className="">{profile.bio}</p>
126+
<p className="">{profile.bio ?? ""}</p>
88127
<button
89128
onClick={handleCopyLink}
90129
className="mt-4 px-4 py-2 bg-blue-600 rounded hover:bg-blue-800 transition text-white"
@@ -93,6 +132,12 @@ export default function ContributorProfile() {
93132
</button>
94133
</div>
95134

135+
{errorMsg ? (
136+
<div className="mt-4 mb-2 rounded border border-red-300 bg-red-50 text-red-700 dark:border-red-700/40 dark:bg-red-900/20 dark:text-red-300 px-3 py-2">
137+
{errorMsg}
138+
</div>
139+
) : null}
140+
96141
<h3 className="text-xl font-semibold mt-6 mb-2">Pull Requests</h3>
97142
{prs.length > 0 ? (
98143
<ul className="list-disc ml-6 space-y-2">
@@ -106,7 +151,10 @@ export default function ContributorProfile() {
106151
rel="noopener noreferrer"
107152
className="text-blue-700 dark:text-blue-400 hover:underline"
108153
>
109-
[{repoName}] {pr.title}
154+
{`[${repoName}] ${pr.title}`}
155+
{pr.pull_request?.merged_at ? (
156+
<span className="ml-2 inline-block px-2 py-0.5 text-xs rounded bg-green-600/20 text-green-700 dark:text-green-300">merged</span>
157+
) : null}
110158
</a>
111159
</li>
112160
);

0 commit comments

Comments
 (0)