From 3188b57ccde80bc22324dc6c7b967a42d646df3f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 13 Jul 2026 17:08:20 -0400 Subject: [PATCH 1/3] feat: add global token --- .env.example | 1 + src/github/fetch.ts | 51 +++++++++++++++++++++++++-------------------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index 2139291..1ddea83 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +GITHUB_TOKEN=your_token GITHUB_USERNAMES=your_username GITHUB_ORGS=your_organization IGNORED_REPOS=repo1,repo2 diff --git a/src/github/fetch.ts b/src/github/fetch.ts index e3dbef8..bd303d4 100644 --- a/src/github/fetch.ts +++ b/src/github/fetch.ts @@ -36,6 +36,7 @@ export function resetCache(): void { } async function fetchAndAggregate(now: number): Promise { + const globalToken = process.env["GITHUB_TOKEN"]?.trim() || undefined; const usernames = parseSources(process.env["GITHUB_USERNAMES"]); const orgs = parseSources(process.env["GITHUB_ORGS"]); @@ -48,33 +49,37 @@ async function fetchAndAggregate(now: number): Promise { let hadFetchFailure = false; const repoGroups = await Promise.all([ - ...usernames.map(u => - fetchAllRepos( - u.token ? `https://api.github.com/user/repos?per_page=100&visibility=all&affiliation=owner` - : `https://api.github.com/users/${encodeURIComponent(u.name)}/repos?per_page=100`, - noteReset, - u.token - ) - .then(repos => ({ token: u.token, repos })) - .catch(() => { + ...usernames.map(async u => { + const token = u.token ?? globalToken; + try { + const repos = await fetchAllRepos( + u.token ? `https://api.github.com/user/repos?per_page=100&visibility=all&affiliation=owner` + : `https://api.github.com/users/${encodeURIComponent(u.name)}/repos?per_page=100`, + noteReset, + token + ); + return { token, repos }; + } catch { hadFetchFailure = true; console.error(`Skipping user "${u.name}": failed to fetch repositories.`); - return { token: u.token, repos: [] as Repo[] }; - }) - ), - ...orgs.map(o => - fetchAllRepos( - `https://api.github.com/orgs/${encodeURIComponent(o.name)}/repos?per_page=100`, - noteReset, - o.token - ) - .then(repos => ({ token: o.token, repos })) - .catch(() => { + return { token, repos: [] as Repo[] }; + } + }), + ...orgs.map(async o => { + const token = o.token ?? globalToken; + try { + const repos = await fetchAllRepos( + `https://api.github.com/orgs/${encodeURIComponent(o.name)}/repos?per_page=100`, + noteReset, + token + ); + return { token, repos }; + } catch { hadFetchFailure = true; console.error(`Skipping org "${o.name}": failed to fetch repositories.`); - return { token: o.token, repos: [] as Repo[] }; - }) - ) + return { token, repos: [] as Repo[] }; + } + }) ]); const ignored = process.env["IGNORED_REPOS"]?.split(',').map(s => s.trim()) || []; From e443aac36739bce0d04ad95e7825ce2738563379 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 13 Jul 2026 17:10:46 -0400 Subject: [PATCH 2/3] test: add global GITHUB_TOKEN coverage --- tests/github/fetch.test.ts | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/github/fetch.test.ts b/tests/github/fetch.test.ts index a44288f..8abaf36 100644 --- a/tests/github/fetch.test.ts +++ b/tests/github/fetch.test.ts @@ -28,6 +28,7 @@ const mockErrorResponse = (status: number, statusText = "", headers: Record { beforeEach(() => { + vi.stubEnv("GITHUB_TOKEN", ""); vi.stubEnv("GITHUB_USERNAMES", `["testuser"]`); vi.stubEnv("IGNORED_REPOS", "ignored-repo"); global.fetch = vi.fn(); @@ -466,4 +467,53 @@ describe("fetchLanguageData", () => { .mockResolvedValueOnce(mockResponse({ Go: 100 })); expect(await fetchLanguageData()).toEqual({ Go: 100 }); }); + + it("applies global GITHUB_TOKEN to token-less sources without switching URL", async () => { + vi.stubEnv("GITHUB_TOKEN", "global-token"); + + mockFetch() + .mockResolvedValueOnce(mockResponse([repos[0]])) + .mockResolvedValueOnce(mockResponse(languages)); + + await fetchLanguageData(); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/users/testuser/repos?per_page=100", + { headers: { Authorization: "Bearer global-token" } } + ); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/repos/user/repo1/languages", + { headers: { Authorization: "Bearer global-token" } } + ); + }); + + it("prefers per-source token over global GITHUB_TOKEN", async () => { + vi.stubEnv("GITHUB_TOKEN", "global-token"); + vi.stubEnv("GITHUB_USERNAMES", '[{"name": "testuser", "token": "per-source"}]'); + + mockFetch() + .mockResolvedValueOnce(mockResponse([repos[0]])) + .mockResolvedValueOnce(mockResponse(languages)); + + await fetchLanguageData(); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/user/repos?per_page=100&visibility=all&affiliation=owner", + { headers: { Authorization: "Bearer per-source" } } + ); + }); + + it("treats whitespace-only GITHUB_TOKEN as unset", async () => { + vi.stubEnv("GITHUB_TOKEN", " "); + + mockFetch() + .mockResolvedValueOnce(mockResponse([repos[0]])) + .mockResolvedValueOnce(mockResponse(languages)); + + await fetchLanguageData(); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/users/testuser/repos?per_page=100", {} + ); + }); }); From d472144d1a8af0668a7e522b0529ec4f05fa66df Mon Sep 17 00:00:00 2001 From: Masonlet Date: Mon, 13 Jul 2026 17:19:35 -0400 Subject: [PATCH 3/3] docs: document global GITHUB_TOKEN in README --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7f6161d..fe3a7f4 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,16 @@ Deployable **GitHub language chart generator** — embeddable SVGs for READMEs a - [License](#license) ## Features -- Generates a chart of your top programming languages (up to 16). +- **Generates a chart of your top programming languages (up to 16).** - **Customizable:** Control the title, size, theme, and number of languages displayed. - **Theming**: Supports `default`, `light`, and `dark` themes. - **Custom Colours**: Set background (`bg`), text (`text`), and individual language colours (`c1`-`c16`) via query parameters. - **Dynamic Layout:** The legend automatically shifts to a **two-column layout** when displaying 9 or more languages. -- Automatically fetches all public GitHub repositories. Users or organization sources with a token include private repos exposed to that token. -- Ignores forks and optionally specific repositories (`IGNORED_REPOS`). -- Uses **hourly caching** to reduce API calls and improve performance. On fetch failure the last good chart is served and refresh retries after 5 minutes, or when GitHub rate-limits the real reset time. +- **Global Token:** Optional deployment-wide default token, applied to any source without its own `token`, boosting limits from 60 req/hr to 5,000/hr. +- **Automatically fetches GitHub repositories:** Public user and organization sources are automatically fetched; private repos can be fetched if tokens have access. +- **Automatically ignores forks**. +- **Ignored repositories**: Repos ignored via env (`IGNORED_REPOS`). +- **Hourly caching**: Used to reduce API calls and improve performance. On fetch failure the last good chart is served and refresh retries after either 5 minutes, or when GitHub rate-limit timer resets. ## Customize Your Charts Prefer a visual workflow? Use the [@gh-top-languages/builder](https://github.com/gh-top-languages/builder) to preview themes, colours, and layout options interactively, then easily copy the generated embed URL to quickly deploy. @@ -96,6 +98,7 @@ Copy `.env.example` to `.env`, and update the variables. - `GITHUB_USERNAMES`: GitHub usernames to fetch repositories from. Accepts a single value (`masonlet`), comma-separated (`masonlet,secondlet`), or a JSON array with optional per-user tokens (`["masonlet", {"name": "other", "token": "github_pat_..."}]`). - `GITHUB_ORGS`: GitHub organization names to fetch repositories from. Accepts a single value (`gh-top-languages`), comma-separated (`gh-top-languages,starweb-libs`), or a JSON array with optional per-org tokens (`["gh-top-languages", {"name": "starweb-libs", "token": "github_pat_..."}]`) - `IGNORED_REPOS`: Optional comma-separated repo names to exclude from the chart. Accepts a bare name (`repo`, excluded from all sources) or `owner/name` (`org/repo`, scoped to one source). +- `GITHUB_TOKEN`: Optional deployment-wide default token, applied to any source without its own `token`. A per-source token always takes precedence; the global token only authenticates public repos (no private repos) while boosting limits (5,000 req/hr instead of 60/hr). ### Running Locally Your endpoint will be available at http://localhost:3000/api/languages (or your configured PORT) @@ -121,7 +124,7 @@ npm start Or, deploy with Vercel: -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/gh-top-languages/api&env=GITHUB_USERNAMES,GITHUB_ORGS,IGNORED_REPOS&envDescription=GITHUB_USERNAMES%20and%2For%20GITHUB_ORGS%3A%20GitHub%20users%2Forgs%20to%20fetch%20repos%20from.%20IGNORED_REPOS%3A%20optional%20comma-separated%20repo%20names%20to%20exclude%20%28bare%20name%20or%20owner%2Fname%29.&envLink=https%3A%2F%2Fgithub.com%2Fgh-top-languages%2Fapi%23configuration) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/gh-top-languages/api&env=GITHUB_USERNAMES,GITHUB_ORGS,GITHUB_TOKEN,IGNORED_REPOS&envDescription=GITHUB_USERNAMES%20and%2For%20GITHUB_ORGS%3A%20GitHub%20users%2Forgs%20to%20fetch%20repos%20from.%20GITHUB_TOKEN%3A%20optional%20global%20fallback%20token%20for%20sources%20without%20their%20own.%20IGNORED_REPOS%3A%20optional%20comma-separated%20repo%20names%20to%20exclude%20%28bare%20name%20or%20owner%2Fname%29.&envLink=https%3A%2F%2Fgithub.com%2Fgh-top-languages%2Fapi%23configuration) ## Error Responses