feat(github): usar o ultimo retrato da contagem quando nao ha token ou a API falha
data/github-stats.json guarda 68 repositorios proprios (25 publicos e 43 privados, sem forks) conferidos em 2026-10-08. Com token valido a contagem ao vivo vale mais; o retrato evita que o hero fique sem o numero. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
942f6bb45c
commit
c0493f1e77
3 files changed
+82
-11
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"checkedAt": "2026-10-08T18:25:53Z",
|
||||||
|
"total": 68,
|
||||||
|
"public": 25,
|
||||||
|
"private": 43
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
countOwnRepos,
|
countOwnRepos,
|
||||||
GITHUB_STATS_REVALIDATE_SECONDS,
|
GITHUB_STATS_REVALIDATE_SECONDS,
|
||||||
loadGithubRepoCounts,
|
loadGithubRepoCounts,
|
||||||
|
loadGithubStatsSnapshot,
|
||||||
} from "./github-stats";
|
} from "./github-stats";
|
||||||
|
|
||||||
const repo = (
|
const repo = (
|
||||||
@@ -16,6 +17,9 @@ function jsonResponse(body: unknown, status = 200) {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noSnapshot = async () => null;
|
||||||
|
const snapshot = { total: 68, public: 25, private: 43 };
|
||||||
|
|
||||||
describe("countOwnRepos", () => {
|
describe("countOwnRepos", () => {
|
||||||
it("counts public and private repos and ignores forks and archived ones", () => {
|
it("counts public and private repos and ignores forks and archived ones", () => {
|
||||||
const counts = countOwnRepos([
|
const counts = countOwnRepos([
|
||||||
@@ -33,19 +37,38 @@ describe("countOwnRepos", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("loadGithubRepoCounts", () => {
|
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();
|
const fetchImpl = jest.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
loadGithubRepoCounts(undefined, fetchImpl),
|
loadGithubRepoCounts(undefined, fetchImpl, noSnapshot),
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
expect(fetchImpl).not.toHaveBeenCalled();
|
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 () => {
|
it("sends the token only to the GitHub API and revalidates daily", async () => {
|
||||||
const fetchImpl = jest.fn().mockResolvedValue(jsonResponse([repo({})]));
|
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];
|
const [url, init] = fetchImpl.mock.calls[0];
|
||||||
expect(new URL(url).origin).toBe("https://api.github.com");
|
expect(new URL(url).origin).toBe("https://api.github.com");
|
||||||
@@ -64,7 +87,9 @@ describe("loadGithubRepoCounts", () => {
|
|||||||
.mockResolvedValueOnce(jsonResponse(firstPage))
|
.mockResolvedValueOnce(jsonResponse(firstPage))
|
||||||
.mockResolvedValueOnce(jsonResponse(secondPage));
|
.mockResolvedValueOnce(jsonResponse(secondPage));
|
||||||
|
|
||||||
await expect(loadGithubRepoCounts("token", fetchImpl)).resolves.toEqual({
|
await expect(
|
||||||
|
loadGithubRepoCounts("token", fetchImpl, noSnapshot),
|
||||||
|
).resolves.toEqual({
|
||||||
total: 101,
|
total: 101,
|
||||||
public: 100,
|
public: 100,
|
||||||
private: 1,
|
private: 1,
|
||||||
@@ -72,17 +97,30 @@ describe("loadGithubRepoCounts", () => {
|
|||||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
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
|
const fetchImpl = jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(jsonResponse({ message: "Bad credentials" }, 401));
|
.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"));
|
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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+30
-3
@@ -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.
|
* 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_
|
* 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.
|
* 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;
|
export const GITHUB_STATS_REVALIDATE_SECONDS = 24 * 60 * 60;
|
||||||
@@ -42,9 +47,9 @@ export function countOwnRepos(repos: RepoSummary[]): GithubRepoCounts {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadGithubRepoCounts(
|
async function fetchGithubRepoCounts(
|
||||||
token: string | undefined = process.env.GITHUB_STATS_TOKEN,
|
token: string | undefined,
|
||||||
fetchImpl: FetchLike = fetch,
|
fetchImpl: FetchLike,
|
||||||
): Promise<GithubRepoCounts | null> {
|
): Promise<GithubRepoCounts | null> {
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
|
|
||||||
@@ -80,3 +85,25 @@ export async function loadGithubRepoCounts(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Último retrato conhecido (data/github-stats.json); `null` se o arquivo faltar ou estiver incoerente. */
|
||||||
|
export async function loadGithubStatsSnapshot(): Promise<GithubRepoCounts | null> {
|
||||||
|
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<GithubRepoCounts | null> = loadGithubStatsSnapshot,
|
||||||
|
): Promise<GithubRepoCounts | null> {
|
||||||
|
return (await fetchGithubRepoCounts(token, fetchImpl)) ?? readSnapshot();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user