Skip to content
Merged
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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ GITHUB_PR_COUNT=80
GITHUB_ISSUE_COUNT=20
GITHUB_DISCUSSION_COUNT=10

# Redis caching (optional)
# Leaderboard source data
LEADERBOARD_SOURCE_URL_TEMPLATE=https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/{country}.yml

# Redis caching (optional — strongly recommended for leaderboard performance)
# Use either redis://localhost:6379 or include password if enabled: redis://:password@localhost:6379
REDIS_URL=
REDIS_ENABLED=false
REDIS_PASSWORD=
REDIS_CACHE_NAMESPACE=devimpact:v1
REDIS_CACHE_TTL_SECONDS=604800
REDIS_CONNECT_TIMEOUT_MS=1500

# ── PostgreSQL (local development) ─────────────────
DATABASE_URL=postgresql://devimpact:devimpact@localhost:5432/devimpact?sslmode=disable
POSTGRES_PASSWORD=CHANGE_THIS_TO_A_LONG_RANDOM_PASSWORD
59 changes: 59 additions & 0 deletions .github/workflows/leaderboard-scheduler.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Leaderboard Scheduler

on:
schedule:
- cron: "0 17 * * *"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: leaderboard-scheduler
cancel-in-progress: false

jobs:
calculate-next-country:
name: Calculate next country leaderboard
runs-on: ubuntu-latest
timeout-minutes: 30
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN_DEVIMPACT }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
LEADERBOARD_SOURCE_URL_TEMPLATE: ${{ vars.LEADERBOARD_SOURCE_URL_TEMPLATE || 'https://raw.githubusercontent.com/ashkulz/committers.top/gh-pages/_data/locations/{country}.yml' }}
GITHUB_REPO_COUNT: ${{ vars.GITHUB_REPO_COUNT || '50' }}
GITHUB_PR_COUNT: ${{ vars.GITHUB_PR_COUNT || '300' }}
GITHUB_ISSUE_COUNT: ${{ vars.GITHUB_ISSUE_COUNT || '100' }}
GITHUB_DISCUSSION_COUNT: ${{ vars.GITHUB_DISCUSSION_COUNT || '50' }}
REDIS_URL: ${{ secrets.REDIS_URL }}
REDIS_ENABLED: ${{ vars.REDIS_ENABLED || 'false' }}
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
REDIS_CACHE_NAMESPACE: ${{ vars.REDIS_CACHE_NAMESPACE || 'devimpact:v1' }}
REDIS_CACHE_TTL_SECONDS: ${{ vars.REDIS_CACHE_TTL_SECONDS || '604800' }}
REDIS_CONNECT_TIMEOUT_MS: ${{ vars.REDIS_CONNECT_TIMEOUT_MS || '1500' }}
LEADERBOARD_SEED_LIMIT: ${{ vars.LEADERBOARD_SEED_LIMIT || '256' }}
LEADERBOARD_REFRESH_LIMIT: ${{ vars.LEADERBOARD_REFRESH_LIMIT || '500' }}
LEADERBOARD_USER_STALE_DAYS: ${{ vars.LEADERBOARD_USER_STALE_DAYS || '30' }}
GITHUB_USER_STALE_DAYS: ${{ vars.GITHUB_USER_STALE_DAYS || '14' }}

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.29.2
run_install: false

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24.x
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Calculate next country
run: pnpm leaderboard:calculate
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ GITHUB_DISCUSSION_COUNT=10
pnpm run dev
```

To calculate the next leaderboard country from the script, run:

```bash
pnpm run calculate-next-country
```

---


Expand Down
29 changes: 29 additions & 0 deletions app/api/calculate-leaderboard/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { calculateLeaderboard } from "@/lib/calculate-leaderboard";

export const runtime = "nodejs";

export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const country = searchParams.get("country")?.trim();

if (!country) {
return NextResponse.json(
{ success: false, error: "Provide a country parameter" },
{ status: 400 },
);
}

try {
const result = await calculateLeaderboard(country);
return NextResponse.json({ success: true, ...result });
} catch (err) {
return NextResponse.json(
{
success: false,
error: err instanceof Error ? err.message : "Failed to calculate leaderboard",
},
{ status: 502 },
);
}
}
85 changes: 73 additions & 12 deletions app/api/compare/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { NextResponse } from "next/server";
import { fetchGitHubUserData } from "../../../lib/github";
import { getUserData } from "../../../lib/github";
import { calculateUserScore } from "../../../lib/score";
import { normalizeSelectedLanguages } from "@/lib/scoring/languageScoring";
import { toSafeApiError } from "@/lib/github-graphql-client";
import { getDatabaseStore } from "@/lib/db-store";
import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store";
import { detectCountry } from "@/lib/location-detector";
import type { CompareInsights, SafeApiError } from "@/types/api-response";
import {
DEFAULT_LOCALE,
Expand All @@ -11,6 +14,7 @@ import {
parseAcceptLanguage,
type Locale,
} from "@/lib/i18n-core";
import { GitHubUserData } from "@/types/github";

export const runtime = "nodejs";

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

type ClientSafeError = Pick<SafeApiError, "code" | "message" | "targetUsernames">;
type ClientSafeError = Pick<
SafeApiError,
"code" | "message" | "targetUsernames"
>;

function parseSelectedLanguagesFromSearchParams(
searchParams: URLSearchParams,
Expand All @@ -60,7 +67,10 @@ function parseSelectedLanguagesFromSearchParams(
.map((language) => language.trim())
.filter(Boolean);

return normalizeSelectedLanguages([...(fromRepeated ?? []), ...(fromCsv ?? [])]);
return normalizeSelectedLanguages([
...(fromRepeated ?? []),
...(fromCsv ?? []),
]);
}

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

const [userA, userB] = users;
const overallWinner = userA.finalScore >= userB.finalScore ? userA : userB;
const overallLoser = overallWinner.username === userA.username ? userB : userA;
const overallLoser =
overallWinner.username === userA.username ? userB : userA;
const overallDifference = Math.abs(userA.finalScore - userB.finalScore);
const overallPercentage =
overallLoser.finalScore > 0
Expand Down Expand Up @@ -114,7 +125,8 @@ function calculateWinner(users: ComparedUserResult[]): {
userA.languageScores.finalScore >= userB.languageScores.finalScore
? userA
: userB;
const languageLoser = languageWinner.username === userA.username ? userB : userA;
const languageLoser =
languageWinner.username === userA.username ? userB : userA;
const winnerLanguageScores = languageWinner.languageScores!;
const loserLanguageScores = languageLoser.languageScores!;
const languageDifference = Math.abs(
Expand Down Expand Up @@ -306,9 +318,14 @@ async function compareUsers(
const results: ComparedUserResult[] = [];

for (const username of usernames) {
let data: Awaited<ReturnType<typeof fetchGitHubUserData>>;
let data: Awaited<GitHubUserData>;
try {
data = await fetchGitHubUserData(username);
const { data: userData, metrics } = await getUserData(username, {
cacheInRedis: true,
withMetrics: true,
});
data = userData;
console.log(metrics);
} catch (error: unknown) {
throw new CompareUserFetchError(username, error);
}
Expand All @@ -322,7 +339,7 @@ async function compareUsers(
);

results.push({
username,
username: data.login,
name: data.name,
avatarUrl: data.avatarUrl,
repoScore: Math.round(score.repoScore),
Expand All @@ -331,7 +348,9 @@ async function compareUsers(
finalScore: Math.round(score.finalScore),
normalizedRepoScore: Math.round(score.normalizedRepoScore),
normalizedPRScore: Math.round(score.normalizedPRScore),
normalizedContributionScore: Math.round(score.normalizedContributionScore),
normalizedContributionScore: Math.round(
score.normalizedContributionScore,
),
normalizedFinalScore: Math.round(score.normalizedFinalScore),
topRepos: score.topRepos,
topPullRequests: score.topPullRequests,
Expand All @@ -340,18 +359,59 @@ async function compareUsers(
signals: score.signals,
explanations: score.explanations,
});

// ── Fire-and-forget: detect country & upsert into DB ──────────────
const country = detectCountry(data.location);
if (country) {
const staleDays = parseInt(
process.env.GITHUB_USER_STALE_DAYS ?? "14",
10,
);

const db = getDatabaseStore();
db.upsertUser({
username: data.login,
name: data.name,
avatarUrl: data.avatarUrl,
location: data.location,
country,
rawData: data,
scores: score,
repoScore: Math.round(score.repoScore),
prScore: Math.round(score.prScore),
contributionScore: Math.round(score.contributionScore),
finalScore: Math.round(score.finalScore),
staleDays,
})
.then(() => {
// Invalidate Redis cache for this country
const cacheConfig = getCacheConfigFromEnv();
const cacheStore = createCacheStore(cacheConfig);
if (cacheStore.enabled && cacheStore.del) {
const key = `${cacheConfig.namespace}:leaderboard:${country}`;
cacheStore.del(key).catch(() => {});
}
})
.catch((err: unknown) => {
console.warn("Failed to upsert user from compare:", err);
});
}
}

return results;
}

function toApiErrorStatus(code: ReturnType<typeof toSafeApiError>["code"]): number {
function toApiErrorStatus(
code: ReturnType<typeof toSafeApiError>["code"],
): number {
switch (code) {
case "RATE_LIMITED":
case "TEMPORARY_THROTTLE":
return 429;
case "GITHUB_TIMEOUT":
case "GITHUB_RESOURCE_LIMIT":
case "GITHUB_AUTH":
return 401;
return code === "GITHUB_AUTH" ? 401 : 503;
case "GITHUB_NOT_FOUND":
return 404;
case "NETWORK":
Expand Down Expand Up @@ -385,7 +445,8 @@ export async function GET(request: Request) {

try {
const locale = resolveLocale(request);
const selectedLanguages = parseSelectedLanguagesFromSearchParams(searchParams);
const selectedLanguages =
parseSelectedLanguagesFromSearchParams(searchParams);
const users = await compareUsers(usernames, selectedLanguages);
const winnerData = calculateWinner(users);
const insights = createComparisonInsights(users, locale);
Expand Down
31 changes: 31 additions & 0 deletions app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { getLeaderboardResult } from "@/lib/leaderboard";

export const runtime = "nodejs";

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const country = searchParams.get("country")?.trim();

if (!country) {
return NextResponse.json(
{ success: false, error: "Provide a country parameter" },
{ status: 400 },
);
}

try {
const result = await getLeaderboardResult(country);
return NextResponse.json({ success: true, ...result });
} catch (err) {
console.error("Leaderboard DB query failed:", err);

return NextResponse.json({
success: true,
title: country,
totalFromSource: 0,
scored: [],
errors: [],
});
}
}
2 changes: 2 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@import "flag-icons/css/flag-icons.min.css";

@tailwind base;
@tailwind components;
@tailwind utilities;
Expand Down
Loading
Loading