From 0077aaed70d39523695236a0f04db1a3040b2c33 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 16:58:29 -0400 Subject: [PATCH 01/15] feat: add per-source token support --- src/services/github.ts | 62 +++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/src/services/github.ts b/src/services/github.ts index b500ef5..55058cc 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -1,11 +1,33 @@ import { REFRESH_INTERVAL } from "@gh-top-languages/lib/constants/config.js"; import type { Language } from "@gh-top-languages/lib/types.js"; +type Source = { name: string; token?: string }; + type LanguageBytes = Record; let cachedLanguageData: LanguageBytes | null = null; let lastRefresh = 0; +function parseSources(env: string | undefined): Source[] { + if (!env) return []; + try { + const parsed = JSON.parse(env); + if (Array.isArray(parsed)) { + return parsed.map(entry => + typeof entry === "string" ? { name: entry } : entry + ); + } + + } catch { + + } + return env.split(',').map(s => ({ name: s.trim() })).filter(s => s.name); +} + +function makeOptions(token?: string): RequestInit { + return token ? { headers: { Authorization: `Bearer ${token}` } } : {}; +} + function parseNextLink(linkHeader: string | null): string | null { if (!linkHeader) return null; const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/); @@ -18,14 +40,15 @@ type Repo = { full_name: string; }; -async function fetchAllRepos(url: string, options: RequestInit): Promise { +async function fetchAllRepos(url: string, token?: string): Promise { + const options = makeOptions(token); let nextUrl: string | null = url; const repos: Repo[] = []; while (nextUrl) { const response = await fetch(nextUrl, options); if (!response.ok) throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); - repos.push(...await response.json() as Repo[]); + repos.push(...(await response.json() as Repo[])); nextUrl = parseNextLink(response.headers.get("Link")); } @@ -39,31 +62,38 @@ export async function fetchLanguageData(useTestData = false): Promise u.trim()).filter(Boolean) || []; - const orgs = process.env["GITHUB_ORGS" ]?.split(',').map(o => o.trim()).filter(Boolean) || []; + const usernames = parseSources(process.env["GITHUB_USERNAMES"]); + const orgs = parseSources(process.env["GITHUB_ORGS"]); - if(usernames.length === 0 && orgs.length === 0) throw new Error( + if (usernames.length === 0 && orgs.length === 0) throw new Error( "At least one of GITHUB_USERNAMES or GITHUB_ORGS must be set" ); - const token: string | undefined = process.env["GITHUB_TOKEN"]; - const options: RequestInit = token ? { headers: { Authorization: `Bearer ${token}` } } : {}; - const repoArrays = await Promise.all([ - ...usernames.map(user => fetchAllRepos(`https://api.github.com/users/${user}/repos?per_page=100`, options)), - ...orgs.map( org => fetchAllRepos(`https://api.github.com/orgs/${org}/repos?per_page=100`, options)) + ...usernames.map( + u => fetchAllRepos(`https://api.github.com/users/${u.name}/repos?per_page=100`, u.token) + ), + ...orgs.map( + o => fetchAllRepos(`https://api.github.com/orgs/${o.name}/repos?per_page=100`, o.token) + ) ]); const repos = repoArrays.flat(); const ignored = process.env["IGNORED_REPOS"]?.split(',').map(name => name.trim()) || []; const filteredRepos = repos.filter(repo => !repo.fork && !ignored.includes(repo.name)); - const languageFetches = filteredRepos.map( - repo => fetch(`https://api.github.com/repos/${repo.full_name}/languages`, options).then(r => r.ok ? r.json() : {}) - ); + const tokenMap = new Map(); + [...usernames, ...orgs].forEach(s => { if (s.token) tokenMap.set(s.name, s.token); }); + + const languageFetches = filteredRepos.map(repo => { + const owner = repo.full_name.split('/')[0] ?? ''; + return fetch( + `https://api.github.com/repos/${repo.full_name}/languages`, makeOptions(tokenMap.get(owner)) + ).then(r => r.ok ? (r.json() as Promise) : ({} as LanguageBytes)) + .catch(() => ({} as LanguageBytes)); + }); const langResults: LanguageBytes[] = await Promise.all(languageFetches); @@ -79,7 +109,7 @@ export async function fetchLanguageData(useTestData = false): Promise a + b, 0); From 407c500a7c379c6faa39ad964d7fd440872196f3 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:16:14 -0400 Subject: [PATCH 02/15] chore: update env.example for per-source tokens --- .env.example | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 087d85b..d292087 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ -GITHUB_USERNAMES=your_username(s) -GITHUB_ORGS=your_org(s) +GITHUB_USERNAMES=["your_username", {"name": "other_username", "token": "github_pat_"}] +GITHUB_ORGS=["your_org", {"name": "other_org", "token": "github_pat_" }] IGNORED_REPOS=ignored_repo(s) -GITHUB_TOKEN= From 62c6bdf78aa5eea8b9770b264e996c3079803eec Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:21:18 -0400 Subject: [PATCH 03/15] docs: update readme for per-source tokens --- README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e2fa794..e31d26a 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,12 @@ Deployable **GitHub language chart generator** — embeddable SVGs for READMEs a - [License](#license) ## Features -- Generates a donut 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 your public GitHub repositories. +- Automatically fetches all public GitHub repositories, and private repositories with a token. - Ignores forks and optionally specific repositories (`IGNORED_REPOS`). - Uses **hourly caching** to reduce API calls and improve performance. @@ -101,10 +101,9 @@ npm install ### Configuration Copy `.env.example` to `.env`, and update the variables. -- `GITHUB_USERNAMES`: Comma-separated GitHub usernames to fetch repositories from. -- `GITHUB_ORGS`: Optional comma-separated GitHub organization names to include. +- `GITHUB_USERNAMES`: GitHub usernames to fetch repositories from. Accepts a plain string (`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 plain string (`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. -- `GITHUB_TOKEN`: Optional GitHub personal access token. Raises the API rate limit from 60 to 5000 requests/hour. ### Running Locally ```bash @@ -114,10 +113,10 @@ vercel dev ### Deployment -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/masonlet/github-top-languages-api&env=GITHUB_USERNAMES,GITHUB_TOKEN,IGNORED_REPOS&envDescription[GITHUB_USERNAMES]=Comma-separated%20GitHub%20usernames&envDescription[IGNORED_REPOS]=Optional%20comma-separated%20repos%20to%20exclude&envDescription[GITHUB_TOKEN]=Optional%20GitHub%20personal%20access%20token%20for%20higher%20rate%20limits) - > The default endpoint is /api/languages +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/masonlet/github-top-languages-api&env=GITHUB_USERNAMES,GITHUB_ORGS,IGNORED_REPOS&envDescription[GITHUB_USERNAMES]=JSON%20array%20or%20plain%20string%20of%20GitHub%20usernames.%20Add%20a%20token%20per%20entry%20for%20private%20repos%3A%20%5B%22user%22%2C%20%7B%22name%22%3A%20%22other%22%2C%20%22token%22%3A%20%22github_pat_%22%7D%5D&envDescription[GITHUB_ORGS]=JSON%20array%20or%20plain%20string%20of%20GitHub%20org%20names.%20Add%20a%20token%20per%20entry%20for%20private%20org%20repos&envDescription[IGNORED_REPOS]=Optional%20comma-separated%20repo%20names%20to%20exclude) + ## Error Responses All errors return HTTP 200 with an error SVG so they render in GitHub README embeds. From d16188e9bf8c89a52167d3cf0160fac29c3eb1d3 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:27:58 -0400 Subject: [PATCH 04/15] refactor: language fetches use scoped repo groups --- src/services/github.ts | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/services/github.ts b/src/services/github.ts index 55058cc..95c06a9 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -71,29 +71,26 @@ export async function fetchLanguageData(useTestData = false): Promise fetchAllRepos(`https://api.github.com/users/${u.name}/repos?per_page=100`, u.token) + const repoGroups = await Promise.all([ + ...usernames.map(u => + fetchAllRepos(`https://api.github.com/users/${u.name}/repos?per_page=100`, u.token) + .then(repos => ({ token: u.token, repos })) ), - ...orgs.map( - o => fetchAllRepos(`https://api.github.com/orgs/${o.name}/repos?per_page=100`, o.token) + ...orgs.map(o => + fetchAllRepos(`https://api.github.com/orgs/${o.name}/repos?per_page=100`, o.token) + .then(repos => ({ token: o.token, repos })) ) ]); - const repos = repoArrays.flat(); - const ignored = process.env["IGNORED_REPOS"]?.split(',').map(name => name.trim()) || []; - const filteredRepos = repos.filter(repo => !repo.fork && !ignored.includes(repo.name)); + const ignored = process.env["IGNORED_REPOS"]?.split(',').map(name => name.trim()) || []; - const tokenMap = new Map(); - [...usernames, ...orgs].forEach(s => { if (s.token) tokenMap.set(s.name, s.token); }); - - const languageFetches = filteredRepos.map(repo => { - const owner = repo.full_name.split('/')[0] ?? ''; - return fetch( - `https://api.github.com/repos/${repo.full_name}/languages`, makeOptions(tokenMap.get(owner)) - ).then(r => r.ok ? (r.json() as Promise) : ({} as LanguageBytes)) - .catch(() => ({} as LanguageBytes)); - }); + const languageFetches = repoGroups.flatMap(({ token, repos }) => + repos.filter(repo => !repo.fork && !ignored.includes(repo.name)).map(repo => + fetch(`https://api.github.com/repos/${repo.full_name}/languages`, makeOptions(token)) + .then(r => r.ok ? (r.json() as Promise) : ({} as LanguageBytes)) + .catch(() => ({} as LanguageBytes)) + ) + ); const langResults: LanguageBytes[] = await Promise.all(languageFetches); From 8017a50395b608959d59c18bb43dfbabc8cabd71 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:30:40 -0400 Subject: [PATCH 05/15] fix: strip input in parseSources fallback --- src/services/github.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/github.ts b/src/services/github.ts index 95c06a9..752fea2 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -21,7 +21,7 @@ function parseSources(env: string | undefined): Source[] { } catch { } - return env.split(',').map(s => ({ name: s.trim() })).filter(s => s.name); + return env.split(',').map(s => ({ name: s.trim().replace(/^["']|["']$/g, "") })).filter(s => s.name); } function makeOptions(token?: string): RequestInit { From c881e6f58f127ccf066fd157070764b315f4b14a Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:34:24 -0400 Subject: [PATCH 06/15] feat: harden parseSources with type validation and error logging --- src/services/github.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/services/github.ts b/src/services/github.ts index 752fea2..ac80fd1 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -13,13 +13,16 @@ function parseSources(env: string | undefined): Source[] { try { const parsed = JSON.parse(env); if (Array.isArray(parsed)) { - return parsed.map(entry => - typeof entry === "string" ? { name: entry } : entry - ); + return parsed.map(entry => { + if (typeof entry === "string") return { name: entry }; + if (entry && typeof entry === "object" && "name" in entry) return { + name: String(entry.name), ...(entry.token && {token: String(entry.token) }) + }; + return null; + }).filter((s): s is Source => !!s && !!s.name); } - - } catch { - + } catch (e) { + console.error("Failed to parse env variable:", e); } return env.split(',').map(s => ({ name: s.trim().replace(/^["']|["']$/g, "") })).filter(s => s.name); } From 5453db93f04c1316035dfefeac5cc54335cb0cc9 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:36:53 -0400 Subject: [PATCH 07/15] fix: fail partial json --- src/services/github.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/github.ts b/src/services/github.ts index ac80fd1..1dd58f6 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -24,6 +24,7 @@ function parseSources(env: string | undefined): Source[] { } catch (e) { console.error("Failed to parse env variable:", e); } + if (env.trimStart().startsWith('[')) return []; return env.split(',').map(s => ({ name: s.trim().replace(/^["']|["']$/g, "") })).filter(s => s.name); } From 4ee3132fbeb17f8feee16e55a7b2057f2746279f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:41:33 -0400 Subject: [PATCH 08/15] fix: update tests for per-source token format --- tests/services/github.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/services/github.test.ts b/tests/services/github.test.ts index b5b22a4..08cac6c 100644 --- a/tests/services/github.test.ts +++ b/tests/services/github.test.ts @@ -27,7 +27,7 @@ const mockErrorResponse = (status: number, statusText = "") => ( describe("fetchLanguageData", () => { beforeEach(() => { - vi.stubEnv("GITHUB_USERNAMES", "testuser"); + vi.stubEnv("GITHUB_USERNAMES", `["testuser"]`); vi.stubEnv("IGNORED_REPOS", "ignored-repo"); global.fetch = vi.fn(); vi.resetModules(); @@ -52,7 +52,7 @@ describe("fetchLanguageData", () => { it("handles missing IGNORED_REPOS env variable", async () => { vi.unstubAllEnvs(); - vi.stubEnv("GITHUB_USERNAMES", "testuser"); + vi.stubEnv("GITHUB_USERNAMES", `["testuser"]`); mockFetch() .mockResolvedValueOnce(mockResponse(repos)) @@ -136,7 +136,7 @@ describe("fetchLanguageData", () => { it("fetches from organizations", async () => { vi.unstubAllEnvs(); - vi.stubEnv("GITHUB_ORGS", "test-org"); + vi.stubEnv("GITHUB_ORGS", `["test-org"]`); const orgRepos = [ { name: "org-repo", fork: false, full_name: "test-org/org-repo" } @@ -187,8 +187,8 @@ describe("fetchLanguageData", () => { expect(result).toEqual({ Go: 300 }); }); - it("sends Authorization header when GITHUB_TOKEN is set", async () => { - vi.stubEnv("GITHUB_TOKEN", "test-token"); + it("sends Authorization header when token is set per source", async () => { + vi.stubEnv("GITHUB_USERNAMES", '[{"name": "testuser", "token": "test-token"}]'); mockFetch() .mockResolvedValueOnce(mockResponse([repos[0]])) @@ -200,6 +200,10 @@ describe("fetchLanguageData", () => { "https://api.github.com/users/testuser/repos?per_page=100", { headers: { Authorization: "Bearer test-token" } } ); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/repos/user/repo1/languages", + { headers: { Authorization: "Bearer test-token" } } + ); }); }); From 48fff100d9dca6b721f373c2a13aeb170b038a4e Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:45:11 -0400 Subject: [PATCH 09/15] feat: add edge case tests and suppress console.error in tests --- tests/services/github.test.ts | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/services/github.test.ts b/tests/services/github.test.ts index 08cac6c..ed1450c 100644 --- a/tests/services/github.test.ts +++ b/tests/services/github.test.ts @@ -32,6 +32,7 @@ describe("fetchLanguageData", () => { global.fetch = vi.fn(); vi.resetModules(); resetCache(); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { @@ -205,6 +206,72 @@ describe("fetchLanguageData", () => { { headers: { Authorization: "Bearer test-token" } } ); }); + + it("parses CSV fallback for GITHUB_USERNAMES", async () => { + vi.stubEnv("GITHUB_USERNAMES", "testuser"); + + mockFetch() + .mockResolvedValueOnce(mockResponse([repos[0]])) + .mockResolvedValueOnce(mockResponse(languages)); + + await fetchLanguageData(); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/users/testuser/repos?per_page=100", {} + ); + }); + + it("returns empty sources for broken JSON array", async () => { + vi.unstubAllEnvs(); + vi.stubEnv("GITHUB_USERNAMES", '["testuser"'); + + await expect(fetchLanguageData()).rejects.toThrow( + "At least one of GITHUB_USERNAMES or GITHUB_ORGS must be set" + ); + }); + + it("skips malformed entries in JSON array", async () => { + vi.stubEnv("GITHUB_USERNAMES", '[123, "testuser"]'); + + mockFetch() + .mockResolvedValueOnce(mockResponse([repos[0]])) + .mockResolvedValueOnce(mockResponse(languages)); + + await fetchLanguageData(); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/users/testuser/repos?per_page=100", {} + ); + }); + + it("handles language fetch network failure gracefully", async () => { + mockFetch() + .mockResolvedValueOnce(mockResponse([repos[0]])) + .mockRejectedValueOnce(new Error("Network error")); + + const result = await fetchLanguageData(); + expect(result).toEqual({}); + }); + + it("sends Authorization header for org token", async () => { + vi.unstubAllEnvs(); + vi.stubEnv("GITHUB_ORGS", '[{"name": "test-org", "token": "org-token"}]'); + + mockFetch() + .mockResolvedValueOnce(mockResponse([{ name: "org-repo", fork: false, full_name: "test-org/org-repo" }])) + .mockResolvedValueOnce(mockResponse({ TypeScript: 4000 })); + + await fetchLanguageData(); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/orgs/test-org/repos?per_page=100", + { headers: { Authorization: "Bearer org-token" } } + ); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/repos/test-org/org-repo/languages", + { headers: { Authorization: "Bearer org-token" } } + ); + }); }); describe("processLanguageData", () => { From e13187b702ff8114754c8dc4874ab4e0f45a4ae2 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:48:04 -0400 Subject: [PATCH 10/15] refactor: static error mgs to prevent data leaks --- src/services/github.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/github.ts b/src/services/github.ts index 1dd58f6..e00f46d 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -22,7 +22,7 @@ function parseSources(env: string | undefined): Source[] { }).filter((s): s is Source => !!s && !!s.name); } } catch (e) { - console.error("Failed to parse env variable:", e); + console.error("Failed to parse configuration string."); } if (env.trimStart().startsWith('[')) return []; return env.split(',').map(s => ({ name: s.trim().replace(/^["']|["']$/g, "") })).filter(s => s.name); From c087a1d8a0577ead9faa2896b7e015aaa9265974 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:48:43 -0400 Subject: [PATCH 11/15] refactor: simplify deploy descriptions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e31d26a..43e40d1 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ vercel dev > The default endpoint is /api/languages -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/masonlet/github-top-languages-api&env=GITHUB_USERNAMES,GITHUB_ORGS,IGNORED_REPOS&envDescription[GITHUB_USERNAMES]=JSON%20array%20or%20plain%20string%20of%20GitHub%20usernames.%20Add%20a%20token%20per%20entry%20for%20private%20repos%3A%20%5B%22user%22%2C%20%7B%22name%22%3A%20%22other%22%2C%20%22token%22%3A%20%22github_pat_%22%7D%5D&envDescription[GITHUB_ORGS]=JSON%20array%20or%20plain%20string%20of%20GitHub%20org%20names.%20Add%20a%20token%20per%20entry%20for%20private%20org%20repos&envDescription[IGNORED_REPOS]=Optional%20comma-separated%20repo%20names%20to%20exclude) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/masonlet/github-top-languages-api&env=GITHUB_USERNAMES,GITHUB_ORGS,IGNORED_REPOS&envDescription[GITHUB_USERNAMES]=GitHub%20usernames%20to%20fetch%20repos%20from.%20See%20README%20for%20format.&envDescription[GITHUB_ORGS]=GitHub%20org%20names%20to%20fetch%20repos%20from.%20See%20README%20for%20format.&envDescription[IGNORED_REPOS]=Optional%20comma-separated%20repo%20names%20to%20exclude) ## Error Responses From 7b1c1786cd862dadcf7aed9430a646be224980af Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 17:57:22 -0400 Subject: [PATCH 12/15] feat: harden parseSources validation and add pagination url guard --- .env.example | 10 ++++-- src/services/github.ts | 45 +++++++++++++++++++-------- tests/services/github.test.ts | 57 +++++++++++++++++++++++++++++++++-- 3 files changed, 93 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index d292087..2139291 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ -GITHUB_USERNAMES=["your_username", {"name": "other_username", "token": "github_pat_"}] -GITHUB_ORGS=["your_org", {"name": "other_org", "token": "github_pat_" }] -IGNORED_REPOS=ignored_repo(s) +GITHUB_USERNAMES=your_username +GITHUB_ORGS=your_organization +IGNORED_REPOS=repo1,repo2 + +# Advanced: JSON array with optional per-entry tokens +# GITHUB_USERNAMES=["your_username", {"name": "other_username", "token": "github_pat_"}] +# GITHUB_ORGS=["your_org", {"name": "private_org", "token": "github_pat_"}] diff --git a/src/services/github.ts b/src/services/github.ts index e00f46d..11c6065 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -10,22 +10,30 @@ let lastRefresh = 0; function parseSources(env: string | undefined): Source[] { if (!env) return []; - try { - const parsed = JSON.parse(env); - if (Array.isArray(parsed)) { - return parsed.map(entry => { - if (typeof entry === "string") return { name: entry }; - if (entry && typeof entry === "object" && "name" in entry) return { - name: String(entry.name), ...(entry.token && {token: String(entry.token) }) - }; + + const trimmed = env.trim(); + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(env); + return (parsed as unknown[]).map(entry => { + if (typeof entry === "string" && entry.trim()) return { name: entry.trim() }; + if (entry && typeof entry === "object" && "name" in entry && typeof entry.name === "string" && entry.name.trim()) { + const source: Source = { name: entry.name.trim() }; + if ("token" in entry + && typeof entry.token === "string" + && entry.token.trim() + ) source.token = entry.token.trim(); + return source; + } return null; - }).filter((s): s is Source => !!s && !!s.name); + }).filter((s): s is Source => !!s); + } catch { + console.error("Failed to parse configuration JSON array."); + throw new Error("GITHUB_USERNAMES/GITHUB_ORGS must be a valid JSON array. Check your configuration."); } - } catch (e) { - console.error("Failed to parse configuration string."); } - if (env.trimStart().startsWith('[')) return []; - return env.split(',').map(s => ({ name: s.trim().replace(/^["']|["']$/g, "") })).filter(s => s.name); + + return trimmed.split(',').map(s => ({ name: s.trim().replace(/^["']|["']$/g, "") })).filter(s => s.name); } function makeOptions(token?: string): RequestInit { @@ -54,6 +62,9 @@ async function fetchAllRepos(url: string, token?: string): Promise { if (!response.ok) throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); repos.push(...(await response.json() as Repo[])); nextUrl = parseNextLink(response.headers.get("Link")); + if (nextUrl && !nextUrl.startsWith("https://api.github.com/")) throw new Error( + `Unexpected pagination URL: ${nextUrl}` + ); } return repos; @@ -79,10 +90,18 @@ export async function fetchLanguageData(useTestData = false): Promise fetchAllRepos(`https://api.github.com/users/${u.name}/repos?per_page=100`, u.token) .then(repos => ({ token: u.token, repos })) + .catch(err => { + console.error(`Skipping user "${u.name}":`, err.message); + return { token: u.token, repos: [] as Repo[] }; + }) ), ...orgs.map(o => fetchAllRepos(`https://api.github.com/orgs/${o.name}/repos?per_page=100`, o.token) .then(repos => ({ token: o.token, repos })) + .catch(err => { + console.error(`Skipping org "${o.name}":`, err.message); + return { token: o.token, repos: [] as Repo[] }; + }) ) ]); diff --git a/tests/services/github.test.ts b/tests/services/github.test.ts index ed1450c..00d411d 100644 --- a/tests/services/github.test.ts +++ b/tests/services/github.test.ts @@ -102,11 +102,23 @@ describe("fetchLanguageData", () => { expect(result).toEqual({ JavaScript: 1500, Python: 300 }); }); - it("throws on repos API error", async () => { + it("handles repos API error gracefully", async () => { mockFetch() .mockResolvedValueOnce(mockErrorResponse(404, "Not Found")); - await expect(fetchLanguageData()).rejects.toThrow("GitHub API error: 404 Not Found"); + const result = await fetchLanguageData(); + expect(result).toEqual({}); + }); + + it("handles org repos API error gracefully", async () => { + vi.unstubAllEnvs(); + vi.stubEnv("GITHUB_ORGS", '["test-org"]'); + + mockFetch() + .mockResolvedValueOnce(mockErrorResponse(403, "Forbidden")); + + const result = await fetchLanguageData(); + expect(result).toEqual({}); }); it("caches results within refresh interval", async () => { @@ -226,7 +238,7 @@ describe("fetchLanguageData", () => { vi.stubEnv("GITHUB_USERNAMES", '["testuser"'); await expect(fetchLanguageData()).rejects.toThrow( - "At least one of GITHUB_USERNAMES or GITHUB_ORGS must be set" + "GITHUB_USERNAMES/GITHUB_ORGS must be a valid JSON array. Check your configuration." ); }); @@ -272,6 +284,45 @@ describe("fetchLanguageData", () => { { headers: { Authorization: "Bearer org-token" } } ); }); + + it("trims whitespace from plain string entries in JSON array", async () => { + vi.stubEnv("GITHUB_USERNAMES", '[" testuser "]'); + + mockFetch() + .mockResolvedValueOnce(mockResponse([repos[0]])) + .mockResolvedValueOnce(mockResponse(languages)); + + await fetchLanguageData(); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/users/testuser/repos?per_page=100", {} + ); + }); + + it("handles unexpected pagination URL gracefully", async () => { + mockFetch() + .mockResolvedValueOnce(mockResponse( + [{ name: "repo1", fork: false, full_name: "user/repo1" }], + `; rel="next"` + )); + + const result = await fetchLanguageData(); + expect(result).toEqual({}); + }); + + it("ignores whitespace-only tokens in JSON array", async () => { + vi.stubEnv("GITHUB_USERNAMES", '[{"name": "testuser", "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", {} + ); + }); }); describe("processLanguageData", () => { From 7642f879030b7c8072697e9c698f57e61ed2e380 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 18:43:10 -0400 Subject: [PATCH 13/15] fix: sanitize error logs in repo fetch catch blocks --- src/services/github.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/services/github.ts b/src/services/github.ts index 11c6065..ffaf4b0 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -1,6 +1,12 @@ import { REFRESH_INTERVAL } from "@gh-top-languages/lib/constants/config.js"; import type { Language } from "@gh-top-languages/lib/types.js"; +type Repo = { + name: string; + fork: boolean; + full_name: string; +}; + type Source = { name: string; token?: string }; type LanguageBytes = Record; @@ -46,12 +52,6 @@ function parseNextLink(linkHeader: string | null): string | null { return match?.[1] ?? null; } -type Repo = { - name: string; - fork: boolean; - full_name: string; -}; - async function fetchAllRepos(url: string, token?: string): Promise { const options = makeOptions(token); let nextUrl: string | null = url; @@ -90,16 +90,16 @@ export async function fetchLanguageData(useTestData = false): Promise fetchAllRepos(`https://api.github.com/users/${u.name}/repos?per_page=100`, u.token) .then(repos => ({ token: u.token, repos })) - .catch(err => { - console.error(`Skipping user "${u.name}":`, err.message); + .catch(() => { + 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/${o.name}/repos?per_page=100`, o.token) .then(repos => ({ token: o.token, repos })) - .catch(err => { - console.error(`Skipping org "${o.name}":`, err.message); + .catch(() => { + console.error(`Skipping org "${o.name}": failed to fetch repositories.`); return { token: o.token, repos: [] as Repo[] }; }) ) From 2d56dcb6578204d3f740b102424aa6075a350587 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 18:44:37 -0400 Subject: [PATCH 14/15] fix: prevent cache poisoning on empty fetch results --- src/services/github.ts | 10 ++++++++-- tests/services/github.test.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/services/github.ts b/src/services/github.ts index ffaf4b0..4616c2b 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -117,15 +117,21 @@ export async function fetchLanguageData(useTestData = false): Promise((acc, languages) => { + const result = langResults.reduce((acc, languages) => { for (const [lang, bytes] of Object.entries(languages)) { acc[lang] = (acc[lang] || 0) + bytes; } return acc; }, {}); + if (Object.keys(result).length === 0 && cachedLanguageData !== null) { + lastRefresh = now - REFRESH_INTERVAL + (5 * 60 * 1000); + return cachedLanguageData; + } + + cachedLanguageData = result; lastRefresh = now; - return cachedLanguageData; + return result; } export function processLanguageData(languageBytes: LanguageBytes, count: number): Language[] { diff --git a/tests/services/github.test.ts b/tests/services/github.test.ts index 00d411d..0ea603f 100644 --- a/tests/services/github.test.ts +++ b/tests/services/github.test.ts @@ -323,6 +323,24 @@ describe("fetchLanguageData", () => { "https://api.github.com/users/testuser/repos?per_page=100", {} ); }); + + it("returns stale cache on total fetch failure", async () => { + mockFetch() + .mockResolvedValueOnce(mockResponse([repos[0]])) + .mockResolvedValueOnce(mockResponse(languages)); + + await fetchLanguageData(); + + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1000 * 60 * 61); + + mockFetch() + .mockResolvedValueOnce(mockErrorResponse(500, "Internal Server Error")); + + const result = await fetchLanguageData(); + expect(result).toEqual({ JavaScript: 5000, Python: 3000, HTML: 2000 }); + + vi.restoreAllMocks(); + }); }); describe("processLanguageData", () => { From 76322cf9b01d411de424bc5107cdcf967f9e271f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 13 Jun 2026 18:54:55 -0400 Subject: [PATCH 15/15] refactor: scope stale cache to fetch failures and encode source names in urls --- README.md | 4 ++-- src/services/github.ts | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 43e40d1..233b700 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ npm install ### Configuration Copy `.env.example` to `.env`, and update the variables. -- `GITHUB_USERNAMES`: GitHub usernames to fetch repositories from. Accepts a plain string (`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 plain string (`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_..."}]`) +- `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. ### Running Locally diff --git a/src/services/github.ts b/src/services/github.ts index 4616c2b..a838199 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -86,19 +86,22 @@ export async function fetchLanguageData(useTestData = false): Promise - fetchAllRepos(`https://api.github.com/users/${u.name}/repos?per_page=100`, u.token) + fetchAllRepos(`https://api.github.com/users/${encodeURIComponent(u.name)}/repos?per_page=100`, u.token) .then(repos => ({ token: u.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/${o.name}/repos?per_page=100`, o.token) + fetchAllRepos(`https://api.github.com/orgs/${encodeURIComponent(o.name)}/repos?per_page=100`, o.token) .then(repos => ({ token: o.token, repos })) .catch(() => { + hadFetchFailure = true; console.error(`Skipping org "${o.name}": failed to fetch repositories.`); return { token: o.token, repos: [] as Repo[] }; }) @@ -109,9 +112,9 @@ export async function fetchLanguageData(useTestData = false): Promise repos.filter(repo => !repo.fork && !ignored.includes(repo.name)).map(repo => - fetch(`https://api.github.com/repos/${repo.full_name}/languages`, makeOptions(token)) + fetch(`https://api.github.com/repos/${repo.full_name.split('/').map(encodeURIComponent).join('/')}/languages`, makeOptions(token)) .then(r => r.ok ? (r.json() as Promise) : ({} as LanguageBytes)) - .catch(() => ({} as LanguageBytes)) + .catch(() => { hadFetchFailure = true; return {} as LanguageBytes; }) ) ); @@ -124,7 +127,7 @@ export async function fetchLanguageData(useTestData = false): Promise