diff --git a/data/github-stats.json b/data/github-stats.json new file mode 100644 index 0000000..4cd5eab --- /dev/null +++ b/data/github-stats.json @@ -0,0 +1,6 @@ +{ + "checkedAt": "2026-10-08T18:25:53Z", + "total": 68, + "public": 25, + "private": 43 +} diff --git a/lib/github-stats.test.ts b/lib/github-stats.test.ts index bc740aa..1b23306 100644 --- a/lib/github-stats.test.ts +++ b/lib/github-stats.test.ts @@ -2,6 +2,7 @@ import { countOwnRepos, GITHUB_STATS_REVALIDATE_SECONDS, loadGithubRepoCounts, + loadGithubStatsSnapshot, } from "./github-stats"; const repo = ( @@ -16,6 +17,9 @@ function jsonResponse(body: unknown, status = 200) { } as unknown as Response; } +const noSnapshot = async () => null; +const snapshot = { total: 68, public: 25, private: 43 }; + describe("countOwnRepos", () => { it("counts public and private repos and ignores forks and archived ones", () => { const counts = countOwnRepos([ @@ -33,19 +37,38 @@ describe("countOwnRepos", () => { }); describe("loadGithubRepoCounts", () => { - it("returns null without a token and never calls the API", async () => { + it("returns null without a token and without a snapshot, and never calls the API", async () => { const fetchImpl = jest.fn(); await expect( - loadGithubRepoCounts(undefined, fetchImpl), + loadGithubRepoCounts(undefined, fetchImpl, noSnapshot), ).resolves.toBeNull(); expect(fetchImpl).not.toHaveBeenCalled(); }); + it("falls back to the last known snapshot without a token", async () => { + const fetchImpl = jest.fn(); + + await expect( + loadGithubRepoCounts(undefined, fetchImpl, async () => snapshot), + ).resolves.toEqual(snapshot); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("prefers the live count over the snapshot", async () => { + const fetchImpl = jest + .fn() + .mockResolvedValue(jsonResponse([repo({}), repo({ private: true })])); + + await expect( + loadGithubRepoCounts("token", fetchImpl, async () => snapshot), + ).resolves.toEqual({ total: 2, public: 1, private: 1 }); + }); + it("sends the token only to the GitHub API and revalidates daily", async () => { const fetchImpl = jest.fn().mockResolvedValue(jsonResponse([repo({})])); - await loadGithubRepoCounts("secret-token", fetchImpl); + await loadGithubRepoCounts("secret-token", fetchImpl, noSnapshot); const [url, init] = fetchImpl.mock.calls[0]; expect(new URL(url).origin).toBe("https://api.github.com"); @@ -64,7 +87,9 @@ describe("loadGithubRepoCounts", () => { .mockResolvedValueOnce(jsonResponse(firstPage)) .mockResolvedValueOnce(jsonResponse(secondPage)); - await expect(loadGithubRepoCounts("token", fetchImpl)).resolves.toEqual({ + await expect( + loadGithubRepoCounts("token", fetchImpl, noSnapshot), + ).resolves.toEqual({ total: 101, public: 100, private: 1, @@ -72,17 +97,30 @@ describe("loadGithubRepoCounts", () => { expect(fetchImpl).toHaveBeenCalledTimes(2); }); - it("returns null when the API rejects the token", async () => { + it("uses the snapshot when the API rejects the token", async () => { const fetchImpl = jest .fn() .mockResolvedValue(jsonResponse({ message: "Bad credentials" }, 401)); - await expect(loadGithubRepoCounts("bad", fetchImpl)).resolves.toBeNull(); + await expect( + loadGithubRepoCounts("bad", fetchImpl, async () => snapshot), + ).resolves.toEqual(snapshot); }); - it("returns null on network failure instead of breaking the page", async () => { + it("uses the snapshot on network failure instead of breaking the page", async () => { const fetchImpl = jest.fn().mockRejectedValue(new Error("offline")); - await expect(loadGithubRepoCounts("token", fetchImpl)).resolves.toBeNull(); + await expect( + loadGithubRepoCounts("token", fetchImpl, async () => snapshot), + ).resolves.toEqual(snapshot); + }); +}); + +describe("loadGithubStatsSnapshot", () => { + it("reads a coherent snapshot from data/github-stats.json", async () => { + const counts = await loadGithubStatsSnapshot(); + + expect(counts).not.toBeNull(); + expect(counts?.total).toBe((counts?.public ?? 0) + (counts?.private ?? 0)); }); }); diff --git a/lib/github-stats.ts b/lib/github-stats.ts index 57bf486..ed42745 100644 --- a/lib/github-stats.ts +++ b/lib/github-stats.ts @@ -1,7 +1,12 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + /** * Contagem de repositórios próprios no GitHub (públicos + privados), consultada só no servidor. * O token (GITHUB_STATS_TOKEN, fine-grained, somente leitura de metadados) nunca recebe prefixo NEXT_PUBLIC_ * e este módulo só é importado por componentes de servidor: ele não chega ao navegador. + * + * Sem token, ou se a API falhar, vale o último retrato conhecido em data/github-stats.json. */ export const GITHUB_STATS_REVALIDATE_SECONDS = 24 * 60 * 60; @@ -42,9 +47,9 @@ export function countOwnRepos(repos: RepoSummary[]): GithubRepoCounts { }; } -export async function loadGithubRepoCounts( - token: string | undefined = process.env.GITHUB_STATS_TOKEN, - fetchImpl: FetchLike = fetch, +async function fetchGithubRepoCounts( + token: string | undefined, + fetchImpl: FetchLike, ): Promise { if (!token) return null; @@ -80,3 +85,25 @@ export async function loadGithubRepoCounts( return null; } } + +/** Último retrato conhecido (data/github-stats.json); `null` se o arquivo faltar ou estiver incoerente. */ +export async function loadGithubStatsSnapshot(): Promise { + try { + const path = resolve(process.cwd(), "data/github-stats.json"); + const raw = JSON.parse(await readFile(path, "utf8")) as GithubRepoCounts; + const counts = [raw.total, raw.public, raw.private]; + if (!counts.every((n) => Number.isInteger(n) && n >= 0)) return null; + if (raw.total !== raw.public + raw.private) return null; + return { total: raw.total, public: raw.public, private: raw.private }; + } catch { + return null; + } +} + +export async function loadGithubRepoCounts( + token: string | undefined = process.env.GITHUB_STATS_TOKEN, + fetchImpl: FetchLike = fetch, + readSnapshot: () => Promise = loadGithubStatsSnapshot, +): Promise { + return (await fetchGithubRepoCounts(token, fetchImpl)) ?? readSnapshot(); +}