Skip to content

Commit 6d3e3cc

Browse files
authored
Merge pull request #179 from O2sa/leaderboard
feat: add location detection for GitHub users and initialize PostgreSQL schema
2 parents 2fa78b4 + a2a25c6 commit 6d3e3cc

47 files changed

Lines changed: 5316 additions & 1973 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@ GITHUB_PR_COUNT=80
1010
GITHUB_ISSUE_COUNT=20
1111
GITHUB_DISCUSSION_COUNT=10
1212

13-
# Redis caching (optional)
13+
# Leaderboard source data
14+
LEADERBOARD_SOURCE_URL_TEMPLATE=https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/{country}.yml
15+
16+
# Redis caching (optional — strongly recommended for leaderboard performance)
1417
# Use either redis://localhost:6379 or include password if enabled: redis://:password@localhost:6379
1518
REDIS_URL=
1619
REDIS_ENABLED=false
1720
REDIS_PASSWORD=
1821
REDIS_CACHE_NAMESPACE=devimpact:v1
1922
REDIS_CACHE_TTL_SECONDS=604800
2023
REDIS_CONNECT_TIMEOUT_MS=1500
24+
25+
# ── PostgreSQL (local development) ─────────────────
26+
DATABASE_URL=postgresql://devimpact:devimpact@localhost:5432/devimpact?sslmode=disable
27+
POSTGRES_PASSWORD=CHANGE_THIS_TO_A_LONG_RANDOM_PASSWORD
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Leaderboard Scheduler
2+
3+
on:
4+
schedule:
5+
- cron: "0 17 * * *"
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
concurrency:
12+
group: leaderboard-scheduler
13+
cancel-in-progress: false
14+
15+
jobs:
16+
calculate-next-country:
17+
name: Calculate next country leaderboard
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 30
20+
env:
21+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN_DEVIMPACT }}
22+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
23+
LEADERBOARD_SOURCE_URL_TEMPLATE: ${{ vars.LEADERBOARD_SOURCE_URL_TEMPLATE || 'https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/{country}.yml' }}
24+
GITHUB_REPO_COUNT: ${{ vars.GITHUB_REPO_COUNT || '50' }}
25+
GITHUB_PR_COUNT: ${{ vars.GITHUB_PR_COUNT || '300' }}
26+
GITHUB_ISSUE_COUNT: ${{ vars.GITHUB_ISSUE_COUNT || '100' }}
27+
GITHUB_DISCUSSION_COUNT: ${{ vars.GITHUB_DISCUSSION_COUNT || '50' }}
28+
REDIS_URL: ${{ secrets.REDIS_URL }}
29+
REDIS_ENABLED: ${{ vars.REDIS_ENABLED || 'false' }}
30+
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
31+
REDIS_CACHE_NAMESPACE: ${{ vars.REDIS_CACHE_NAMESPACE || 'devimpact:v1' }}
32+
REDIS_CACHE_TTL_SECONDS: ${{ vars.REDIS_CACHE_TTL_SECONDS || '604800' }}
33+
REDIS_CONNECT_TIMEOUT_MS: ${{ vars.REDIS_CONNECT_TIMEOUT_MS || '1500' }}
34+
LEADERBOARD_SEED_LIMIT: ${{ vars.LEADERBOARD_SEED_LIMIT || '256' }}
35+
LEADERBOARD_REFRESH_LIMIT: ${{ vars.LEADERBOARD_REFRESH_LIMIT || '500' }}
36+
LEADERBOARD_USER_STALE_DAYS: ${{ vars.LEADERBOARD_USER_STALE_DAYS || '30' }}
37+
GITHUB_USER_STALE_DAYS: ${{ vars.GITHUB_USER_STALE_DAYS || '14' }}
38+
39+
steps:
40+
- name: Checkout
41+
uses: actions/checkout@v4
42+
43+
- name: Setup pnpm
44+
uses: pnpm/action-setup@v4
45+
with:
46+
version: 10.29.2
47+
run_install: false
48+
49+
- name: Setup Node.js
50+
uses: actions/setup-node@v4
51+
with:
52+
node-version: 24.x
53+
cache: pnpm
54+
55+
- name: Install dependencies
56+
run: pnpm install --frozen-lockfile
57+
58+
- name: Calculate next country
59+
run: pnpm leaderboard:calculate

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,12 @@ GITHUB_DISCUSSION_COUNT=10
200200
pnpm run dev
201201
```
202202

203+
To calculate the next leaderboard country from the script, run:
204+
205+
```bash
206+
pnpm run calculate-next-country
207+
```
208+
203209
---
204210

205211

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { NextResponse } from "next/server";
2+
import { calculateLeaderboard } from "@/lib/calculate-leaderboard";
3+
4+
export const runtime = "nodejs";
5+
6+
export async function POST(request: Request) {
7+
const { searchParams } = new URL(request.url);
8+
const country = searchParams.get("country")?.trim();
9+
10+
if (!country) {
11+
return NextResponse.json(
12+
{ success: false, error: "Provide a country parameter" },
13+
{ status: 400 },
14+
);
15+
}
16+
17+
try {
18+
const result = await calculateLeaderboard(country);
19+
return NextResponse.json({ success: true, ...result });
20+
} catch (err) {
21+
return NextResponse.json(
22+
{
23+
success: false,
24+
error: err instanceof Error ? err.message : "Failed to calculate leaderboard",
25+
},
26+
{ status: 502 },
27+
);
28+
}
29+
}

app/api/compare/route.ts

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { NextResponse } from "next/server";
2-
import { fetchGitHubUserData } from "../../../lib/github";
2+
import { getUserData } from "../../../lib/github";
33
import { calculateUserScore } from "../../../lib/score";
44
import { normalizeSelectedLanguages } from "@/lib/scoring/languageScoring";
55
import { toSafeApiError } from "@/lib/github-graphql-client";
6+
import { getDatabaseStore } from "@/lib/db-store";
7+
import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store";
8+
import { detectCountry } from "@/lib/location-detector";
69
import type { CompareInsights, SafeApiError } from "@/types/api-response";
710
import {
811
DEFAULT_LOCALE,
@@ -11,6 +14,7 @@ import {
1114
parseAcceptLanguage,
1215
type Locale,
1316
} from "@/lib/i18n-core";
17+
import { GitHubUserData } from "@/types/github";
1418

1519
export const runtime = "nodejs";
1620

@@ -48,7 +52,10 @@ type ComparedUserResult = {
4852
explanations: ReturnType<typeof calculateUserScore>["explanations"];
4953
};
5054

51-
type ClientSafeError = Pick<SafeApiError, "code" | "message" | "targetUsernames">;
55+
type ClientSafeError = Pick<
56+
SafeApiError,
57+
"code" | "message" | "targetUsernames"
58+
>;
5259

5360
function parseSelectedLanguagesFromSearchParams(
5461
searchParams: URLSearchParams,
@@ -60,7 +67,10 @@ function parseSelectedLanguagesFromSearchParams(
6067
.map((language) => language.trim())
6168
.filter(Boolean);
6269

63-
return normalizeSelectedLanguages([...(fromRepeated ?? []), ...(fromCsv ?? [])]);
70+
return normalizeSelectedLanguages([
71+
...(fromRepeated ?? []),
72+
...(fromCsv ?? []),
73+
]);
6474
}
6575

6676
function calculateWinner(users: ComparedUserResult[]): {
@@ -82,7 +92,8 @@ function calculateWinner(users: ComparedUserResult[]): {
8292

8393
const [userA, userB] = users;
8494
const overallWinner = userA.finalScore >= userB.finalScore ? userA : userB;
85-
const overallLoser = overallWinner.username === userA.username ? userB : userA;
95+
const overallLoser =
96+
overallWinner.username === userA.username ? userB : userA;
8697
const overallDifference = Math.abs(userA.finalScore - userB.finalScore);
8798
const overallPercentage =
8899
overallLoser.finalScore > 0
@@ -114,7 +125,8 @@ function calculateWinner(users: ComparedUserResult[]): {
114125
userA.languageScores.finalScore >= userB.languageScores.finalScore
115126
? userA
116127
: userB;
117-
const languageLoser = languageWinner.username === userA.username ? userB : userA;
128+
const languageLoser =
129+
languageWinner.username === userA.username ? userB : userA;
118130
const winnerLanguageScores = languageWinner.languageScores!;
119131
const loserLanguageScores = languageLoser.languageScores!;
120132
const languageDifference = Math.abs(
@@ -306,9 +318,14 @@ async function compareUsers(
306318
const results: ComparedUserResult[] = [];
307319

308320
for (const username of usernames) {
309-
let data: Awaited<ReturnType<typeof fetchGitHubUserData>>;
321+
let data: Awaited<GitHubUserData>;
310322
try {
311-
data = await fetchGitHubUserData(username);
323+
const { data: userData, metrics } = await getUserData(username, {
324+
cacheInRedis: true,
325+
withMetrics: true,
326+
});
327+
data = userData;
328+
console.log(metrics);
312329
} catch (error: unknown) {
313330
throw new CompareUserFetchError(username, error);
314331
}
@@ -322,7 +339,7 @@ async function compareUsers(
322339
);
323340

324341
results.push({
325-
username,
342+
username: data.login,
326343
name: data.name,
327344
avatarUrl: data.avatarUrl,
328345
repoScore: Math.round(score.repoScore),
@@ -331,7 +348,9 @@ async function compareUsers(
331348
finalScore: Math.round(score.finalScore),
332349
normalizedRepoScore: Math.round(score.normalizedRepoScore),
333350
normalizedPRScore: Math.round(score.normalizedPRScore),
334-
normalizedContributionScore: Math.round(score.normalizedContributionScore),
351+
normalizedContributionScore: Math.round(
352+
score.normalizedContributionScore,
353+
),
335354
normalizedFinalScore: Math.round(score.normalizedFinalScore),
336355
topRepos: score.topRepos,
337356
topPullRequests: score.topPullRequests,
@@ -340,18 +359,59 @@ async function compareUsers(
340359
signals: score.signals,
341360
explanations: score.explanations,
342361
});
362+
363+
// ── Fire-and-forget: detect country & upsert into DB ──────────────
364+
const country = detectCountry(data.location);
365+
if (country) {
366+
const staleDays = parseInt(
367+
process.env.GITHUB_USER_STALE_DAYS ?? "14",
368+
10,
369+
);
370+
371+
const db = getDatabaseStore();
372+
db.upsertUser({
373+
username: data.login,
374+
name: data.name,
375+
avatarUrl: data.avatarUrl,
376+
location: data.location,
377+
country,
378+
rawData: data,
379+
scores: score,
380+
repoScore: Math.round(score.repoScore),
381+
prScore: Math.round(score.prScore),
382+
contributionScore: Math.round(score.contributionScore),
383+
finalScore: Math.round(score.finalScore),
384+
staleDays,
385+
})
386+
.then(() => {
387+
// Invalidate Redis cache for this country
388+
const cacheConfig = getCacheConfigFromEnv();
389+
const cacheStore = createCacheStore(cacheConfig);
390+
if (cacheStore.enabled && cacheStore.del) {
391+
const key = `${cacheConfig.namespace}:leaderboard:${country}`;
392+
cacheStore.del(key).catch(() => {});
393+
}
394+
})
395+
.catch((err: unknown) => {
396+
console.warn("Failed to upsert user from compare:", err);
397+
});
398+
}
343399
}
344400

345401
return results;
346402
}
347403

348-
function toApiErrorStatus(code: ReturnType<typeof toSafeApiError>["code"]): number {
404+
function toApiErrorStatus(
405+
code: ReturnType<typeof toSafeApiError>["code"],
406+
): number {
349407
switch (code) {
350408
case "RATE_LIMITED":
351409
case "TEMPORARY_THROTTLE":
352410
return 429;
411+
case "GITHUB_TIMEOUT":
412+
case "GITHUB_RESOURCE_LIMIT":
353413
case "GITHUB_AUTH":
354-
return 401;
414+
return code === "GITHUB_AUTH" ? 401 : 503;
355415
case "GITHUB_NOT_FOUND":
356416
return 404;
357417
case "NETWORK":
@@ -385,7 +445,8 @@ export async function GET(request: Request) {
385445

386446
try {
387447
const locale = resolveLocale(request);
388-
const selectedLanguages = parseSelectedLanguagesFromSearchParams(searchParams);
448+
const selectedLanguages =
449+
parseSelectedLanguagesFromSearchParams(searchParams);
389450
const users = await compareUsers(usernames, selectedLanguages);
390451
const winnerData = calculateWinner(users);
391452
const insights = createComparisonInsights(users, locale);

app/api/leaderboard/route.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { NextResponse } from "next/server";
2+
import { getLeaderboardResult } from "@/lib/leaderboard";
3+
4+
export const runtime = "nodejs";
5+
6+
export async function GET(request: Request) {
7+
const { searchParams } = new URL(request.url);
8+
const country = searchParams.get("country")?.trim();
9+
10+
if (!country) {
11+
return NextResponse.json(
12+
{ success: false, error: "Provide a country parameter" },
13+
{ status: 400 },
14+
);
15+
}
16+
17+
try {
18+
const result = await getLeaderboardResult(country);
19+
return NextResponse.json({ success: true, ...result });
20+
} catch (err) {
21+
console.error("Leaderboard DB query failed:", err);
22+
23+
return NextResponse.json({
24+
success: true,
25+
title: country,
26+
totalFromSource: 0,
27+
scored: [],
28+
errors: [],
29+
});
30+
}
31+
}

app/globals.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@import "flag-icons/css/flag-icons.min.css";
2+
13
@tailwind base;
24
@tailwind components;
35
@tailwind utilities;

0 commit comments

Comments
 (0)