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 <[email protected]>
This commit is contained in:
1 parent
b2f3899dc2
commit
4ba955e2c3
2 files changed
+170
No files matched your search
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGithubRepoCounts(
|
||||||
|
token: string | undefined = process.env.GITHUB_STATS_TOKEN,
|
||||||
|
fetchImpl: FetchLike = fetch,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user