diff --git a/.env.example b/.env.example index 087d85b..2139291 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ -GITHUB_USERNAMES=your_username(s) -GITHUB_ORGS=your_org(s) -IGNORED_REPOS=ignored_repo(s) -GITHUB_TOKEN= +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/README.md b/README.md index e2fa794..233b700 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 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. -- `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]=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 All errors return HTTP 200 with an error SVG so they render in GitHub README embeds. diff --git a/src/services/github.ts b/src/services/github.ts index b500ef5..a838199 100644 --- a/src/services/github.ts +++ b/src/services/github.ts @@ -1,32 +1,70 @@ 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; let cachedLanguageData: LanguageBytes | null = null; let lastRefresh = 0; +function parseSources(env: string | undefined): Source[] { + if (!env) return []; + + 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); + } 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."); + } + } + + return trimmed.split(',').map(s => ({ name: s.trim().replace(/^["']|["']$/g, "") })).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"/); return match?.[1] ?? null; } -type Repo = { - name: string; - fork: boolean; - 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")); + if (nextUrl && !nextUrl.startsWith("https://api.github.com/")) throw new Error( + `Unexpected pagination URL: ${nextUrl}` + ); } return repos; @@ -39,47 +77,68 @@ 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)) + let hadFetchFailure = false; + const repoGroups = await Promise.all([ + ...usernames.map(u => + 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/${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[] }; + }) + ) ]); - 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 languageFetches = filteredRepos.map( - repo => fetch(`https://api.github.com/repos/${repo.full_name}/languages`, options).then(r => r.ok ? r.json() : {}) + 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.split('/').map(encodeURIComponent).join('/')}/languages`, makeOptions(token)) + .then(r => r.ok ? (r.json() as Promise) : ({} as LanguageBytes)) + .catch(() => { hadFetchFailure = true; return {} as LanguageBytes; }) + ) ); const langResults: LanguageBytes[] = await Promise.all(languageFetches); - cachedLanguageData = langResults.reduce((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 && hadFetchFailure && 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[] { - if(Object.keys(languageBytes).length === 0) throw new Error("No language data available"); + if (Object.keys(languageBytes).length === 0) throw new Error("No language data available"); const totalBytes = Object.values(languageBytes).reduce((a, b) => a + b, 0); diff --git a/tests/services/github.test.ts b/tests/services/github.test.ts index b5b22a4..0ea603f 100644 --- a/tests/services/github.test.ts +++ b/tests/services/github.test.ts @@ -27,11 +27,12 @@ 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(); resetCache(); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { @@ -52,7 +53,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)) @@ -101,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 () => { @@ -136,7 +149,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 +200,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 +213,133 @@ 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" } } + ); + }); + + 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( + "GITHUB_USERNAMES/GITHUB_ORGS must be a valid JSON array. Check your configuration." + ); + }); + + 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" } } + ); + }); + + 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", {} + ); + }); + + 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(); }); });