Skip to content

Commit 1f64cc6

Browse files
committed
feat: implement GitHub GraphQL client and refactor GitHub data fetching
- Added a new GitHubGraphQLClient to handle GraphQL requests to the GitHub API with rate limit handling and retry logic. - Refactored GitHub data fetching logic to utilize the new GraphQL client, improving error handling and caching mechanisms. - Removed reaction handling from score calculations, simplifying the scoring logic for issues and discussions. - Updated scoring functions to focus on repository visibility and discussion activity without relying on reactions. - Enhanced caching strategies for GitHub user data to improve performance and reduce API calls.
1 parent 04a25d4 commit 1f64cc6

6 files changed

Lines changed: 1031 additions & 353 deletions

File tree

app/api/compare/route.ts

Lines changed: 232 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ import { NextResponse } from "next/server";
22
import { fetchGitHubUserData } from "../../../lib/github";
33
import { calculateUserScore } from "../../../lib/score";
44
import { normalizeSelectedLanguages } from "@/lib/scoring/languageScoring";
5+
import { toSafeApiError } from "@/lib/github-graphql-client";
6+
import type { CompareInsights } from "@/types/api-response";
7+
import {
8+
DEFAULT_LOCALE,
9+
LOCALE_COOKIE,
10+
isSupportedLocale,
11+
parseAcceptLanguage,
12+
type Locale,
13+
} from "@/lib/i18n-core";
514

615
export const runtime = "nodejs";
716

8-
type CompareRequestBody = {
9-
username1?: string;
10-
username2?: string;
11-
selectedLanguages?: string[];
12-
};
13-
1417
type ComparedUserResult = {
1518
username: string;
1619
name: string | null;
@@ -119,42 +122,223 @@ function calculateWinner(users: ComparedUserResult[]): {
119122
return result;
120123
}
121124

125+
function createComparisonInsights(
126+
users: ComparedUserResult[],
127+
locale: Locale,
128+
): CompareInsights | undefined {
129+
if (users.length !== 2) {
130+
return undefined;
131+
}
132+
133+
const [user1, user2] = users;
134+
const user1Name = user1.name || user1.username;
135+
const user2Name = user2.name || user2.username;
136+
137+
const finalLeader = user1.finalScore >= user2.finalScore ? user1 : user2;
138+
const finalFollower = finalLeader.username === user1.username ? user2 : user1;
139+
const finalDiff = Math.abs(user1.finalScore - user2.finalScore);
140+
141+
const repoLeader = user1.repoScore >= user2.repoScore ? user1 : user2;
142+
const prLeader = user1.prScore >= user2.prScore ? user1 : user2;
143+
const contributionLeader =
144+
user1.contributionScore >= user2.contributionScore ? user1 : user2;
145+
146+
const user1Strengths: string[] = [];
147+
const user2Strengths: string[] = [];
148+
149+
if (repoLeader.username === user1.username) {
150+
user1Strengths.push(
151+
locale === "ar"
152+
? "أثر أقوى في المستودعات ووضوح أعلى للمشاريع."
153+
: "Stronger repository impact and project visibility.",
154+
);
155+
} else {
156+
user2Strengths.push(
157+
locale === "ar"
158+
? "أثر أقوى في المستودعات ووضوح أعلى للمشاريع."
159+
: "Stronger repository impact and project visibility.",
160+
);
161+
}
162+
163+
if (prLeader.username === user1.username) {
164+
user1Strengths.push(
165+
locale === "ar"
166+
? "أثر أعلى من طلبات السحب الخارجية المدمجة."
167+
: "Higher merged external pull request impact.",
168+
);
169+
} else {
170+
user2Strengths.push(
171+
locale === "ar"
172+
? "أثر أعلى من طلبات السحب الخارجية المدمجة."
173+
: "Higher merged external pull request impact.",
174+
);
175+
}
176+
177+
if (contributionLeader.username === user1.username) {
178+
user1Strengths.push(
179+
locale === "ar"
180+
? "أثر أعلى من المساهمات الخارجية في المشاكل والنقاشات."
181+
: "Higher external issue/discussion contribution impact.",
182+
);
183+
} else {
184+
user2Strengths.push(
185+
locale === "ar"
186+
? "أثر أعلى من المساهمات الخارجية في المشاكل والنقاشات."
187+
: "Higher external issue/discussion contribution impact.",
188+
);
189+
}
190+
191+
const recommendationsForUser = (target: ComparedUserResult): string[] => {
192+
const recommendations: string[] = [];
193+
194+
if (target.repoScore < repoLeader.repoScore) {
195+
recommendations.push(
196+
locale === "ar"
197+
? "التركيز على عدد أقل من المستودعات العامة عالية الجودة مع نشاط مستمر."
198+
: "Invest in fewer high-quality public repositories with sustained activity.",
199+
);
200+
}
201+
if (target.prScore < prLeader.prScore) {
202+
recommendations.push(
203+
locale === "ar"
204+
? "زيادة المساهمات المدمجة في مستودعات خارجية ذات وصول مجتمعي أوسع."
205+
: "Increase merged contributions to external repositories with broader community reach.",
206+
);
207+
}
208+
if (target.contributionScore < contributionLeader.contributionScore) {
209+
recommendations.push(
210+
locale === "ar"
211+
? "المشاركة بشكل أكبر في المشاكل والنقاشات الخارجية عالية الأثر لتحسين الحضور المجتمعي."
212+
: "Participate more in high-signal external issues/discussions to improve community impact.",
213+
);
214+
}
215+
if (recommendations.length === 0) {
216+
recommendations.push(
217+
locale === "ar"
218+
? "الحفاظ على الاستمرارية بين المستودعات وطلبات السحب والمشاركة المجتمعية الخارجية."
219+
: "Maintain consistency across repositories, pull requests, and external community engagement.",
220+
);
221+
}
222+
223+
return recommendations;
224+
};
225+
226+
return {
227+
summary:
228+
locale === "ar"
229+
? `${finalLeader.name || finalLeader.username} يتصدر حاليًا درجة الأثر المفتوح المصدر بفارق ${finalDiff} نقطة عن ${finalFollower.name || finalFollower.username}.`
230+
: `${finalLeader.name || finalLeader.username} currently has the higher public open-source impact score by ${finalDiff} points over ${finalFollower.name || finalFollower.username}.`,
231+
keyDifferences: [
232+
locale === "ar"
233+
? `${repoLeader.name || repoLeader.username} يتصدر أثر المستودعات (${repoLeader.repoScore} مقابل ${repoLeader.username === user1.username ? user2.repoScore : user1.repoScore}).`
234+
: `${repoLeader.name || repoLeader.username} leads repository impact (${repoLeader.repoScore} vs ${repoLeader.username === user1.username ? user2.repoScore : user1.repoScore}).`,
235+
locale === "ar"
236+
? `${prLeader.name || prLeader.username} يتصدر أثر طلبات السحب (${prLeader.prScore} مقابل ${prLeader.username === user1.username ? user2.prScore : user1.prScore}).`
237+
: `${prLeader.name || prLeader.username} leads pull request impact (${prLeader.prScore} vs ${prLeader.username === user1.username ? user2.prScore : user1.prScore}).`,
238+
locale === "ar"
239+
? `${contributionLeader.name || contributionLeader.username} يتصدر أثر مساهمات المجتمع (${contributionLeader.contributionScore} مقابل ${contributionLeader.username === user1.username ? user2.contributionScore : user1.contributionScore}).`
240+
: `${contributionLeader.name || contributionLeader.username} leads community contribution impact (${contributionLeader.contributionScore} vs ${contributionLeader.username === user1.username ? user2.contributionScore : user1.contributionScore}).`,
241+
],
242+
user1Strengths:
243+
user1Strengths.length > 0
244+
? user1Strengths
245+
: [
246+
locale === "ar"
247+
? "أثر عام متوازن عبر أهم أبعاد التقييم."
248+
: "Balanced overall impact across major scoring dimensions.",
249+
],
250+
user2Strengths:
251+
user2Strengths.length > 0
252+
? user2Strengths
253+
: [
254+
locale === "ar"
255+
? "أثر عام متوازن عبر أهم أبعاد التقييم."
256+
: "Balanced overall impact across major scoring dimensions.",
257+
],
258+
recommendations: {
259+
user1: recommendationsForUser(user1),
260+
user2: recommendationsForUser(user2),
261+
},
262+
confidenceNote:
263+
locale === "ar"
264+
? `هذه المقارنة حتمية ومبنية على إشارات GitHub العامة الملتقطة لكل من ${user1Name} و${user2Name}.`
265+
: `This comparison is deterministic and based on public GitHub signals captured for ${user1Name} and ${user2Name}.`,
266+
};
267+
}
268+
269+
function resolveLocale(request: Request): Locale {
270+
const cookieHeader = request.headers.get("cookie");
271+
const localeFromCookie = cookieHeader
272+
?.split(";")
273+
.map((value) => value.trim())
274+
.find((value) => value.startsWith(`${LOCALE_COOKIE}=`))
275+
?.split("=")[1];
276+
277+
if (isSupportedLocale(localeFromCookie)) {
278+
return localeFromCookie;
279+
}
280+
281+
return parseAcceptLanguage(
282+
request.headers.get("accept-language"),
283+
["en", "ar"],
284+
DEFAULT_LOCALE,
285+
);
286+
}
287+
122288
async function compareUsers(
123289
usernames: string[],
124290
selectedLanguages: string[],
125291
): Promise<ComparedUserResult[]> {
126-
return Promise.all(
127-
usernames.map(async (username) => {
128-
const data = await fetchGitHubUserData(username);
129-
const score = calculateUserScore(
130-
{
131-
...data,
132-
selectedLanguages,
133-
},
134-
username,
135-
);
292+
const results: ComparedUserResult[] = [];
136293

137-
return {
138-
username,
139-
name: data.name,
140-
avatarUrl: data.avatarUrl,
141-
repoScore: Math.round(score.repoScore),
142-
prScore: Math.round(score.prScore),
143-
contributionScore: Math.round(score.contributionScore),
144-
finalScore: Math.round(score.finalScore),
145-
normalizedRepoScore: Math.round(score.normalizedRepoScore),
146-
normalizedPRScore: Math.round(score.normalizedPRScore),
147-
normalizedContributionScore: Math.round(score.normalizedContributionScore),
148-
normalizedFinalScore: Math.round(score.normalizedFinalScore),
149-
topRepos: score.topRepos,
150-
topPullRequests: score.topPullRequests,
151-
topCommunityContributions: score.topCommunityContributions,
152-
languageScores: score.languageScores,
153-
signals: score.signals,
154-
explanations: score.explanations,
155-
};
156-
}),
157-
);
294+
for (const username of usernames) {
295+
const data = await fetchGitHubUserData(username);
296+
const score = calculateUserScore(
297+
{
298+
...data,
299+
selectedLanguages,
300+
},
301+
username,
302+
);
303+
304+
results.push({
305+
username,
306+
name: data.name,
307+
avatarUrl: data.avatarUrl,
308+
repoScore: Math.round(score.repoScore),
309+
prScore: Math.round(score.prScore),
310+
contributionScore: Math.round(score.contributionScore),
311+
finalScore: Math.round(score.finalScore),
312+
normalizedRepoScore: Math.round(score.normalizedRepoScore),
313+
normalizedPRScore: Math.round(score.normalizedPRScore),
314+
normalizedContributionScore: Math.round(score.normalizedContributionScore),
315+
normalizedFinalScore: Math.round(score.normalizedFinalScore),
316+
topRepos: score.topRepos,
317+
topPullRequests: score.topPullRequests,
318+
topCommunityContributions: score.topCommunityContributions,
319+
languageScores: score.languageScores,
320+
signals: score.signals,
321+
explanations: score.explanations,
322+
});
323+
}
324+
325+
return results;
326+
}
327+
328+
function toApiErrorStatus(code: ReturnType<typeof toSafeApiError>["code"]): number {
329+
switch (code) {
330+
case "RATE_LIMITED":
331+
case "TEMPORARY_THROTTLE":
332+
return 429;
333+
case "GITHUB_AUTH":
334+
return 401;
335+
case "GITHUB_NOT_FOUND":
336+
return 404;
337+
case "NETWORK":
338+
return 503;
339+
default:
340+
return 500;
341+
}
158342
}
159343

160344
export async function GET(request: Request) {
@@ -164,66 +348,34 @@ export async function GET(request: Request) {
164348
.map((username) => username.trim())
165349
.filter(Boolean);
166350

167-
if (usernames.length === 0) {
351+
if (usernames.length !== 2) {
168352
return NextResponse.json(
169-
{ success: false, error: "provide at least one username param" },
353+
{ success: false, error: "provide exactly two username params" },
170354
{ status: 400 },
171355
);
172356
}
173357

174358
try {
359+
const locale = resolveLocale(request);
175360
const selectedLanguages = parseSelectedLanguagesFromSearchParams(searchParams);
176361
const users = await compareUsers(usernames, selectedLanguages);
177362
const winnerData = calculateWinner(users);
178-
return NextResponse.json({ success: true, users, ...winnerData });
363+
const insights = createComparisonInsights(users, locale);
364+
return NextResponse.json({ success: true, users, ...winnerData, insights });
179365
} catch (error: unknown) {
180366
console.error("GitHub score error:", error);
181-
const message =
367+
const safeError =
182368
error instanceof Error && error.message === "User not found"
183-
? "GitHub user not found"
184-
: "Failed to calculate score";
185-
return NextResponse.json({ success: false, error: message }, { status: 500 });
186-
}
187-
}
188-
189-
export async function POST(request: Request) {
190-
let body: CompareRequestBody;
191-
192-
try {
193-
body = (await request.json()) as CompareRequestBody;
194-
} catch {
195-
return NextResponse.json(
196-
{ success: false, error: "Invalid JSON body" },
197-
{ status: 400 },
198-
);
199-
}
369+
? { code: "GITHUB_NOT_FOUND" as const, message: "GitHub user not found" }
370+
: toSafeApiError(error);
200371

201-
const usernames = [body.username1, body.username2]
202-
.map((username) => username?.trim() ?? "")
203-
.filter(Boolean);
204-
205-
if (usernames.length !== 2) {
206372
return NextResponse.json(
207373
{
208374
success: false,
209-
error: "Provide username1 and username2 in the request body",
375+
error: safeError.message,
376+
errorDetails: safeError,
210377
},
211-
{ status: 400 },
378+
{ status: toApiErrorStatus(safeError.code) },
212379
);
213380
}
214-
215-
const selectedLanguages = normalizeSelectedLanguages(body.selectedLanguages);
216-
217-
try {
218-
const users = await compareUsers(usernames, selectedLanguages);
219-
const winnerData = calculateWinner(users);
220-
return NextResponse.json({ success: true, users, ...winnerData });
221-
} catch (error: unknown) {
222-
console.error("GitHub score error:", error);
223-
const message =
224-
error instanceof Error && error.message === "User not found"
225-
? "GitHub user not found"
226-
: "Failed to calculate score";
227-
return NextResponse.json({ success: false, error: message }, { status: 500 });
228-
}
229381
}

0 commit comments

Comments
 (0)