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; 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, }; } async function fetchGithubRepoCounts( token: string | undefined, fetchImpl: FetchLike, ): 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; } } /** Ú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(); }