From 4ba955e2c33aac8c10d2afd01e455b72a0d1780b Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 8 Oct 2026 04:12:03 -0300 Subject: [PATCH] feat(github): contar repositorios proprios (publicos e privados) pela API no servidor Consulta /user/repos com token fine-grained somente leitura de metadados (GITHUB_STATS_TOKEN), pagina, exclui forks e arquivados e devolve null se o token faltar ou a API falhar. Renovacao diaria via revalidate de 86400 s. Co-Authored-By: Claude Sonnet 5.5 --- lib/github-stats.test.ts | 88 ++++++++++++++++++++++++++++++++++++++++ lib/github-stats.ts | 82 +++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 lib/github-stats.test.ts create mode 100644 lib/github-stats.ts diff --git a/lib/github-stats.test.ts b/lib/github-stats.test.ts new file mode 100644 index 0000000..bc740aa --- /dev/null +++ b/lib/github-stats.test.ts @@ -0,0 +1,88 @@ +import { + countOwnRepos, + GITHUB_STATS_REVALIDATE_SECONDS, + loadGithubRepoCounts, +} from "./github-stats"; + +const repo = ( + overrides: Partial<{ private: boolean; fork: boolean; archived: boolean }>, +) => ({ private: false, fork: false, archived: false, ...overrides }); + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as unknown as Response; +} + +describe("countOwnRepos", () => { + it("counts public and private repos and ignores forks and archived ones", () => { + const counts = countOwnRepos([ + repo({}), + repo({}), + repo({ private: true }), + repo({ fork: true }), + repo({ private: true, fork: true }), + repo({ archived: true }), + repo({ private: true, archived: true }), + ]); + + expect(counts).toEqual({ total: 3, public: 2, private: 1 }); + }); +}); + +describe("loadGithubRepoCounts", () => { + it("returns null without a token and never calls the API", async () => { + const fetchImpl = jest.fn(); + + await expect( + loadGithubRepoCounts(undefined, fetchImpl), + ).resolves.toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + 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); + + const [url, init] = fetchImpl.mock.calls[0]; + expect(new URL(url).origin).toBe("https://api.github.com"); + expect(url).toContain("affiliation=owner"); + expect(url).toContain("visibility=all"); + expect(init.headers.authorization).toBe("Bearer secret-token"); + expect(init.next.revalidate).toBe(GITHUB_STATS_REVALIDATE_SECONDS); + expect(GITHUB_STATS_REVALIDATE_SECONDS).toBe(86_400); + }); + + it("walks every page before counting", async () => { + const firstPage = Array.from({ length: 100 }, () => repo({})); + const secondPage = [repo({ private: true }), repo({ fork: true })]; + const fetchImpl = jest + .fn() + .mockResolvedValueOnce(jsonResponse(firstPage)) + .mockResolvedValueOnce(jsonResponse(secondPage)); + + await expect(loadGithubRepoCounts("token", fetchImpl)).resolves.toEqual({ + total: 101, + public: 100, + private: 1, + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("returns null when the API rejects the token", async () => { + const fetchImpl = jest + .fn() + .mockResolvedValue(jsonResponse({ message: "Bad credentials" }, 401)); + + await expect(loadGithubRepoCounts("bad", fetchImpl)).resolves.toBeNull(); + }); + + it("returns null on network failure instead of breaking the page", async () => { + const fetchImpl = jest.fn().mockRejectedValue(new Error("offline")); + + await expect(loadGithubRepoCounts("token", fetchImpl)).resolves.toBeNull(); + }); +}); diff --git a/lib/github-stats.ts b/lib/github-stats.ts new file mode 100644 index 0000000..57bf486 --- /dev/null +++ b/lib/github-stats.ts @@ -0,0 +1,82 @@ +/** + * 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. + */ + +export const GITHUB_STATS_REVALIDATE_SECONDS = 24 * 60 * 60; + +const REPOS_ENDPOINT = "https://api.github.com/user/repos"; +const PAGE_SIZE = 100; +const MAX_PAGES = 20; +const REQUEST_TIMEOUT_MS = 10_000; + +export interface GithubRepoCounts { + total: number; + public: number; + private: number; +} + +interface RepoSummary { + private: boolean; + fork: boolean; + archived: boolean; +} + +type CachedRequestInit = RequestInit & { next: { revalidate: number } }; +type FetchLike = (url: string, init: CachedRequestInit) => Promise; + +/** Só repositórios meus: forks e arquivados ficam de fora. */ +export function countOwnRepos(repos: RepoSummary[]): GithubRepoCounts { + let publicCount = 0; + let privateCount = 0; + for (const repo of repos) { + if (repo.fork || repo.archived) continue; + if (repo.private) privateCount += 1; + else publicCount += 1; + } + return { + total: publicCount + privateCount, + public: publicCount, + private: privateCount, + }; +} + +export async function loadGithubRepoCounts( + token: string | undefined = process.env.GITHUB_STATS_TOKEN, + fetchImpl: FetchLike = fetch, +): Promise { + if (!token) return null; + + const repos: RepoSummary[] = []; + try { + for (let page = 1; page <= MAX_PAGES; page += 1) { + const url = `${REPOS_ENDPOINT}?affiliation=owner&visibility=all&per_page=${PAGE_SIZE}&page=${page}`; + const response = await fetchImpl(url, { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "evandro-dev-portfolio", + "x-github-api-version": "2022-11-28", + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + next: { revalidate: GITHUB_STATS_REVALIDATE_SECONDS }, + }); + if (!response.ok) return null; + + const batch = (await response.json()) as RepoSummary[]; + if (!Array.isArray(batch)) return null; + repos.push( + ...batch.map(({ private: isPrivate, fork, archived }) => ({ + private: isPrivate, + fork, + archived, + })), + ); + if (batch.length < PAGE_SIZE) return countOwnRepos(repos); + } + return null; + } catch { + return null; + } +}