Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
GITHUB_TOKEN=your_token
GITHUB_USERNAMES=your_username
GITHUB_ORGS=your_organization
IGNORED_REPOS=repo1,repo2
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down
51 changes: 28 additions & 23 deletions src/github/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function resetCache(): void {
}

async function fetchAndAggregate(now: number): Promise<LanguageBytes> {
const globalToken = process.env["GITHUB_TOKEN"]?.trim() || undefined;
const usernames = parseSources(process.env["GITHUB_USERNAMES"]);
const orgs = parseSources(process.env["GITHUB_ORGS"]);

Expand All @@ -48,33 +49,37 @@ async function fetchAndAggregate(now: number): Promise<LanguageBytes> {

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()) || [];
Expand Down
50 changes: 50 additions & 0 deletions tests/github/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const mockErrorResponse = (status: number, statusText = "", headers: Record<stri

describe("fetchLanguageData", () => {
beforeEach(() => {
vi.stubEnv("GITHUB_TOKEN", "");
vi.stubEnv("GITHUB_USERNAMES", `["testuser"]`);
vi.stubEnv("IGNORED_REPOS", "ignored-repo");
global.fetch = vi.fn();
Expand Down Expand Up @@ -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", {}
);
});
});
Loading