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]>
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
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<Response>;
|
|
|
|
/** 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<GithubRepoCounts | null> {
|
|
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<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();
|
|
}
|