Skip to content

Commit cfd921f

Browse files
committed
feat: add standalone script for calculating next country's leaderboard
- Implemented `calculate-next-country.ts` to automate leaderboard calculations. - The script connects to the PostgreSQL database and processes the next country in line. - Added logging for better tracking of the calculation process and results. refactor: update GitHub data fetching in tests - Renamed `fetchGitHubUserData` to `getUserData` for clarity. - Adjusted tests to use the new `getUserData` mock function. - Enhanced error handling in tests for GitHub API rate limits and resource limits. chore: remove unused ContributionTotals type - Deleted `ContributionTotals` type from GitHub types and related fixtures. - Updated user score input to remove contributions from its structure.
1 parent be540c2 commit cfd921f

18 files changed

Lines changed: 3094 additions & 2228 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ GITHUB_PR_COUNT=80
1010
GITHUB_ISSUE_COUNT=20
1111
GITHUB_DISCUSSION_COUNT=10
1212

13+
# Leaderboard source data
14+
LEADERBOARD_SOURCE_URL_TEMPLATE=https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/{country}.yml
15+
1316
# 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=
@@ -18,3 +21,7 @@ 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

README.md

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

205+
To calculate the next leaderboard country from the script, run:
206+
207+
```bash
208+
pnpm run calculate-next-country
209+
```
210+
205211
---
206212

207213

Lines changed: 4 additions & 253 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,8 @@
11
import { NextResponse } from "next/server";
2-
import yaml from "js-yaml";
3-
import { fetchGitHubUserData } from "@/lib/github";
4-
import { calculateUserScore } from "@/lib/score";
5-
import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store";
6-
import { getDatabaseStore } from "@/lib/db-store";
7-
import { detectCountry } from "@/lib/location-detector";
2+
import { calculateLeaderboard } from "@/lib/calculate-leaderboard";
83

94
export const runtime = "nodejs";
105

11-
// ─── Types ─────────────────────────────────────────────────────────────
12-
13-
type CommitterEntry = {
14-
rank: number;
15-
name: string;
16-
login: string;
17-
avatarUrl: string;
18-
contributions: number;
19-
};
20-
21-
type CommitterYaml = {
22-
title?: string;
23-
total_user_count?: number;
24-
users?: CommitterEntry[];
25-
};
26-
27-
type ScoredEntry = {
28-
username: string;
29-
name: string | null;
30-
avatarUrl: string;
31-
repoScore: number;
32-
prScore: number;
33-
contributionScore: number;
34-
finalScore: number;
35-
originalRank: number;
36-
originalContributions: number;
37-
impactRank: number;
38-
};
39-
40-
type LeaderboardResult = {
41-
title: string;
42-
totalFromSource: number;
43-
scored: ScoredEntry[];
44-
errors: string[];
45-
};
46-
47-
// ─── Helpers ────────────────────────────────────────────────────────────
48-
49-
function buildLeaderboardCacheKey(country: string, namespace: string): string {
50-
return `${namespace}:leaderboard:${country.trim().toLowerCase()}`;
51-
}
52-
53-
function getEnvInt(key: string, fallback: number): number {
54-
const raw = process.env[key]?.trim();
55-
if (!raw) return fallback;
56-
const parsed = Number.parseInt(raw, 10);
57-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
58-
}
59-
60-
function getStaleDays(): number {
61-
return getEnvInt("LEADERBOARD_USER_STALE_DAYS", 30);
62-
}
63-
64-
function getSeedLimit(): number {
65-
return getEnvInt("LEADERBOARD_SEED_LIMIT", 256);
66-
}
67-
68-
function getRefreshLimit(): number {
69-
return getEnvInt("LEADERBOARD_REFRESH_LIMIT", 500);
70-
}
71-
72-
// ─── POST handler ──────────────────────────────────────────────────────
73-
746
export async function POST(request: Request) {
757
const { searchParams } = new URL(request.url);
768
const country = searchParams.get("country")?.trim();
@@ -82,197 +14,16 @@ export async function POST(request: Request) {
8214
);
8315
}
8416

85-
// Ensure database schema exists
86-
const db = getDatabaseStore();
87-
await db.initializeSchema();
88-
89-
// ── 1. Fetch committers from committers.top ──────────────────────────
90-
let committersData: {
91-
title: string;
92-
totalFromSource: number;
93-
users: CommitterEntry[];
94-
};
9517
try {
96-
const url = `https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/${country}.yml`;
97-
const res = await fetch(url, {
98-
headers: { "User-Agent": "DevImpact-Bot" },
99-
});
100-
if (!res.ok) {
101-
return NextResponse.json(
102-
{
103-
success: false,
104-
error: `Failed to fetch committers data for "${country}"`,
105-
},
106-
{ status: 502 },
107-
);
108-
}
109-
const text = await res.text();
110-
const data = yaml.load(text) as CommitterYaml;
111-
if (!data?.users) {
112-
return NextResponse.json(
113-
{
114-
success: false,
115-
error: `Invalid or empty committers data for "${country}"`,
116-
},
117-
{ status: 502 },
118-
);
119-
}
120-
committersData = {
121-
title: data.title || country,
122-
totalFromSource: data.total_user_count ?? data.users.length,
123-
users: data.users,
124-
};
18+
const result = await calculateLeaderboard(country);
19+
return NextResponse.json({ success: true, ...result });
12520
} catch (err) {
12621
return NextResponse.json(
12722
{
12823
success: false,
129-
error:
130-
err instanceof Error
131-
? err.message
132-
: "Failed to fetch committers data",
24+
error: err instanceof Error ? err.message : "Failed to calculate leaderboard",
13325
},
13426
{ status: 502 },
13527
);
13628
}
137-
138-
// ── 2a. Seed NEW users from committers.top ───────────────────────────
139-
const errors: string[] = [];
140-
const seedLimit = getSeedLimit();
141-
const usersToSeed = committersData.users.slice(0, seedLimit);
142-
let newUsersCount = 0;
143-
let skippedExistingCount = 0;
144-
145-
for (const user of usersToSeed) {
146-
try {
147-
const exists = await db.userExists(user.login);
148-
if (exists) {
149-
skippedExistingCount += 1;
150-
continue;
151-
}
152-
153-
const data = await fetchGitHubUserData(user.login);
154-
const score = calculateUserScore(data, user.login);
155-
const countryDetected = detectCountry(data.location);
156-
157-
await db.upsertUser({
158-
username: data.login,
159-
name: data.name,
160-
avatarUrl: data.avatarUrl,
161-
location: data.location,
162-
country: countryDetected,
163-
rawData: data,
164-
scores: score,
165-
repoScore: Math.round(score.repoScore),
166-
prScore: Math.round(score.prScore),
167-
contributionScore: Math.round(score.contributionScore),
168-
finalScore: Math.round(score.finalScore),
169-
staleDays: getStaleDays(),
170-
});
171-
172-
newUsersCount += 1;
173-
} catch {
174-
errors.push(user.login);
175-
}
176-
}
177-
178-
// ── 2b. Refresh STALE users from DB top N ───────────────────────────
179-
const refreshLimit = getRefreshLimit();
180-
const topUsers = await db.getTopUsers(country, refreshLimit);
181-
let refreshedCount = 0;
182-
183-
for (const row of topUsers) {
184-
// Check if stale
185-
if (row.stale_after >= new Date()) {
186-
continue; // still fresh
187-
}
188-
189-
try {
190-
const data = await fetchGitHubUserData(row.username);
191-
const score = calculateUserScore(data, row.username);
192-
const countryDetected = detectCountry(data.location);
193-
194-
await db.upsertUser({
195-
username: data.login,
196-
name: data.name,
197-
avatarUrl: data.avatarUrl,
198-
location: data.location,
199-
country: countryDetected,
200-
rawData: data,
201-
scores: score,
202-
repoScore: Math.round(score.repoScore),
203-
prScore: Math.round(score.prScore),
204-
contributionScore: Math.round(score.contributionScore),
205-
finalScore: Math.round(score.finalScore),
206-
staleDays: getStaleDays(),
207-
});
208-
209-
refreshedCount += 1;
210-
} catch {
211-
errors.push(row.username);
212-
}
213-
}
214-
215-
// ── 3. Build & cache the leaderboard result ──────────────────────────
216-
const allUsers = await db.getLeaderboard(country, getEnvInt("LEADERBOARD_DISPLAY_LIMIT", 500));
217-
const totalCount = await db.getLeaderboardCount(country);
218-
219-
// Build rank lookup from original committers data
220-
const rankMap = new Map(
221-
committersData.users.map((u) => [
222-
u.login,
223-
{ rank: u.rank, contributions: u.contributions },
224-
]),
225-
);
226-
227-
const scored: ScoredEntry[] = allUsers.map((row) => {
228-
const original = rankMap.get(row.username) ?? { rank: 0, contributions: 0 };
229-
return {
230-
username: row.username,
231-
name: row.name,
232-
avatarUrl: row.avatar_url,
233-
repoScore: row.repo_score,
234-
prScore: row.pr_score,
235-
contributionScore: row.contribution_score,
236-
finalScore: row.final_score,
237-
originalRank: original.rank,
238-
originalContributions: original.contributions,
239-
impactRank: 0,
240-
};
241-
});
242-
243-
// Sort and assign impactRank
244-
scored.sort((a, b) => b.finalScore - a.finalScore);
245-
scored.forEach((u, idx) => (u.impactRank = idx + 1));
246-
247-
const result: LeaderboardResult = {
248-
title: committersData.title,
249-
totalFromSource: totalCount,
250-
scored,
251-
errors: [...new Set(errors)], // deduplicate
252-
};
253-
254-
// ── 4. Invalidate Redis cache (lazy rebuild on next GET) ─────────────
255-
try {
256-
const cacheConfig = getCacheConfigFromEnv();
257-
const cacheStore = createCacheStore(cacheConfig);
258-
if (cacheStore.enabled && cacheStore.del) {
259-
const cacheKey = buildLeaderboardCacheKey(country, cacheConfig.namespace);
260-
await cacheStore.del(cacheKey);
261-
}
262-
} catch {
263-
// Non-fatal: cache invalidation failure should not block the response
264-
console.warn("Failed to invalidate leaderboard cache");
265-
}
266-
267-
return NextResponse.json({
268-
success: true,
269-
...result,
270-
_meta: {
271-
newUsers: newUsersCount,
272-
refreshedUsers: refreshedCount,
273-
skippedExisting: skippedExistingCount,
274-
errors: errors.length,
275-
totalInDb: totalCount,
276-
},
277-
});
27829
}

0 commit comments

Comments
 (0)