Skip to content

Commit 12a530c

Browse files
committed
feat: Implement a curated repository catalog with automated data refresh, unavailable repository caching, and improved API cache control.
1 parent 957ccb8 commit 12a530c

14 files changed

Lines changed: 663 additions & 212 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Refresh Repo Catalog
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "0 3 * * 1"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
refresh:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
19+
- name: Setup Node.js
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: 20
23+
cache: npm
24+
25+
- name: Install dependencies
26+
run: npm ci --ignore-scripts
27+
28+
- name: Refresh trending repository dataset
29+
env:
30+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31+
run: npm run data:refresh:repos
32+
33+
- name: Commit refreshed dataset
34+
run: |
35+
if git diff --quiet -- public/data/top-repos.json; then
36+
echo "No dataset changes detected."
37+
exit 0
38+
fi
39+
40+
git config user.name "github-actions[bot]"
41+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
42+
git add public/data/top-repos.json
43+
git commit -m "chore(data): refresh trending repo catalog"
44+
git push

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"prisma:generate": "prisma generate",
1919
"prisma:migrate": "prisma migrate dev",
2020
"prisma:deploy": "node scripts/prisma-deploy.mjs",
21+
"data:refresh:repos": "node scripts/fetch-trending-repos.mjs",
2122
"backfill:kv": "node scripts/backfill-kv-to-postgres.mjs",
2223
"analytics:estimate-gap": "node scripts/estimate-analytics-gap.mjs"
2324
},

scripts/fetch-trending-repos.mjs

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
4+
const githubToken = process.env.GITHUB_TOKEN;
5+
if (!githubToken) {
6+
console.error("❌ GITHUB_TOKEN environment variable is required.");
7+
process.exit(1);
8+
}
9+
10+
const TARGET_REPOS = 250;
11+
const PER_PAGE = 100;
12+
13+
function daysAgoIso(days) {
14+
const date = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
15+
return date.toISOString().slice(0, 10);
16+
}
17+
18+
const TREND_WINDOWS = [
19+
{
20+
label: "7d-hot",
21+
query: `is:public archived:false pushed:>=${daysAgoIso(7)} stars:>=200`,
22+
sort: "updated",
23+
pages: 3,
24+
},
25+
{
26+
label: "14d-rising",
27+
query: `is:public archived:false pushed:>=${daysAgoIso(14)} stars:>=100`,
28+
sort: "updated",
29+
pages: 3,
30+
},
31+
{
32+
label: "30d-active",
33+
query: `is:public archived:false pushed:>=${daysAgoIso(30)} stars:>=50`,
34+
sort: "updated",
35+
pages: 4,
36+
},
37+
{
38+
label: "fallback-popular",
39+
query: "is:public archived:false stars:>=1000",
40+
sort: "stars",
41+
pages: 3,
42+
},
43+
];
44+
45+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
46+
47+
function getErrorMessage(error) {
48+
if (error && typeof error === "object" && "message" in error) {
49+
return String(error.message);
50+
}
51+
return String(error);
52+
}
53+
54+
function parseRateLimitReset(response) {
55+
const resetHeader = response.headers.get("x-ratelimit-reset");
56+
if (!resetHeader) return null;
57+
const resetEpochSeconds = Number(resetHeader);
58+
if (!Number.isFinite(resetEpochSeconds)) return null;
59+
const waitMs = Math.max(0, resetEpochSeconds * 1000 - Date.now());
60+
return waitMs;
61+
}
62+
63+
async function fetchPage(windowConfig, page) {
64+
const url = new URL("https://api.github.com/search/repositories");
65+
url.searchParams.set("q", windowConfig.query);
66+
url.searchParams.set("sort", windowConfig.sort);
67+
url.searchParams.set("order", "desc");
68+
url.searchParams.set("per_page", String(PER_PAGE));
69+
url.searchParams.set("page", String(page));
70+
71+
const response = await fetch(url, {
72+
headers: {
73+
Authorization: `Bearer ${githubToken}`,
74+
Accept: "application/vnd.github+json",
75+
"X-GitHub-Api-Version": "2022-11-28",
76+
"User-Agent": "RepoMind-Weekly-Repo-Catalog",
77+
},
78+
});
79+
80+
if (response.status === 403) {
81+
const waitMs = parseRateLimitReset(response) ?? 60_000;
82+
const waitSeconds = Math.ceil(waitMs / 1000);
83+
console.warn(`⏳ Rate limited. Waiting ${waitSeconds}s before retrying...`);
84+
await sleep(waitMs + 1_000);
85+
return fetchPage(windowConfig, page);
86+
}
87+
88+
if (!response.ok) {
89+
throw new Error(`GitHub API error ${response.status}: ${response.statusText}`);
90+
}
91+
92+
const payload = await response.json();
93+
return Array.isArray(payload?.items) ? payload.items : [];
94+
}
95+
96+
function normalizeRepo(repo) {
97+
return {
98+
owner: repo?.owner?.login ?? "",
99+
repo: repo?.name ?? "",
100+
stars: Number(repo?.stargazers_count ?? 0),
101+
description: typeof repo?.description === "string" ? repo.description : null,
102+
topics: Array.isArray(repo?.topics)
103+
? repo.topics.filter((topic) => typeof topic === "string" && topic.trim().length > 0)
104+
: [],
105+
language: typeof repo?.language === "string" ? repo.language : null,
106+
};
107+
}
108+
109+
function isValidRepo(repo) {
110+
return (
111+
typeof repo.owner === "string" &&
112+
repo.owner.trim().length > 0 &&
113+
typeof repo.repo === "string" &&
114+
repo.repo.trim().length > 0
115+
);
116+
}
117+
118+
async function fetchTrendingRepos() {
119+
const collected = [];
120+
const seen = new Set();
121+
122+
console.log("🚀 Building weekly trending repository catalog...");
123+
124+
for (const windowConfig of TREND_WINDOWS) {
125+
console.log(`\n🔍 Window: ${windowConfig.label}`);
126+
127+
for (let page = 1; page <= windowConfig.pages; page += 1) {
128+
if (collected.length >= TARGET_REPOS) {
129+
break;
130+
}
131+
132+
try {
133+
const items = await fetchPage(windowConfig, page);
134+
if (items.length === 0) {
135+
console.log(` • page ${page}: no results, moving on`);
136+
break;
137+
}
138+
139+
let addedThisPage = 0;
140+
141+
for (const item of items) {
142+
const normalized = normalizeRepo(item);
143+
if (!isValidRepo(normalized)) continue;
144+
145+
const key = `${normalized.owner.toLowerCase()}/${normalized.repo.toLowerCase()}`;
146+
if (seen.has(key)) continue;
147+
148+
seen.add(key);
149+
collected.push(normalized);
150+
addedThisPage += 1;
151+
152+
if (collected.length >= TARGET_REPOS) {
153+
break;
154+
}
155+
}
156+
157+
console.log(` • page ${page}: +${addedThisPage}, total=${collected.length}`);
158+
await sleep(1_000);
159+
} catch (error) {
160+
console.error(` ❌ failed on page ${page}: ${getErrorMessage(error)}`);
161+
break;
162+
}
163+
}
164+
165+
if (collected.length >= TARGET_REPOS) {
166+
break;
167+
}
168+
}
169+
170+
const finalSet = collected.slice(0, TARGET_REPOS);
171+
172+
if (finalSet.length === 0) {
173+
throw new Error("No repositories collected from GitHub search windows.");
174+
}
175+
176+
const dataDir = path.resolve(process.cwd(), "public/data");
177+
fs.mkdirSync(dataDir, { recursive: true });
178+
179+
const outputPath = path.resolve(dataDir, "top-repos.json");
180+
fs.writeFileSync(outputPath, `${JSON.stringify(finalSet, null, 2)}\n`);
181+
182+
console.log(`\n✅ Wrote ${finalSet.length} repositories to ${outputPath}`);
183+
}
184+
185+
fetchTrendingRepos().catch((error) => {
186+
console.error("❌ Failed to refresh trending repository catalog:", getErrorMessage(error));
187+
process.exit(1);
188+
});

src/app/api/stats/badge/route.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,43 @@ import { NextResponse } from "next/server";
44
export const runtime = 'nodejs';
55
export const dynamic = 'force-dynamic';
66

7+
const CACHE_CONTROL = "public, s-maxage=300, stale-while-revalidate=3600";
8+
79
export async function GET() {
810
try {
911
const totalQueries = await kv.get<number>("queries:total");
1012
const count = totalQueries || 0;
1113

1214
// Format for Shields.io Endpoint
1315
// https://shields.io/badges/endpoint-badge
14-
return NextResponse.json({
15-
schemaVersion: 1,
16-
label: "Total Queries",
17-
message: count.toLocaleString(),
18-
color: "blue",
19-
cacheSeconds: 60 // Cache for 1 minute
20-
});
16+
return NextResponse.json(
17+
{
18+
schemaVersion: 1,
19+
label: "Total Queries",
20+
message: count.toLocaleString(),
21+
color: "blue",
22+
cacheSeconds: 300
23+
},
24+
{
25+
headers: {
26+
"Cache-Control": CACHE_CONTROL,
27+
},
28+
}
29+
);
2130
} catch (error) {
2231
console.error("Failed to fetch query stats:", error);
23-
return NextResponse.json({
24-
schemaVersion: 1,
25-
label: "Total Queries",
26-
message: "error",
27-
color: "red"
28-
});
32+
return NextResponse.json(
33+
{
34+
schemaVersion: 1,
35+
label: "Total Queries",
36+
message: "error",
37+
color: "red"
38+
},
39+
{
40+
headers: {
41+
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
42+
},
43+
}
44+
);
2945
}
3046
}

src/app/api/stats/card/route.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { kv } from "@vercel/kv";
44
export const runtime = 'nodejs';
55
export const dynamic = 'force-dynamic';
66

7+
const CACHE_CONTROL = "public, s-maxage=300, stale-while-revalidate=3600";
8+
79
export async function GET() {
810
try {
911
const totalQueries = await kv.get<number>("queries:total");
@@ -115,6 +117,9 @@ export async function GET() {
115117
{
116118
width: 400,
117119
height: 200,
120+
headers: {
121+
"Cache-Control": CACHE_CONTROL,
122+
},
118123
}
119124
);
120125
} catch (error) {

0 commit comments

Comments
 (0)