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
11 changes: 7 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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_"}]
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
113 changes: 86 additions & 27 deletions src/services/github.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;

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<Repo[]> {
async function fetchAllRepos(url: string, token?: string): Promise<Repo[]> {
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;
Expand All @@ -39,47 +77,68 @@ export async function fetchLanguageData(useTestData = false): Promise<LanguageBy
}

const now = Date.now();
if (cachedLanguageData && now - lastRefresh < REFRESH_INTERVAL)
return cachedLanguageData;
if (cachedLanguageData && now - lastRefresh < REFRESH_INTERVAL) return cachedLanguageData;

const usernames = process.env["GITHUB_USERNAMES"]?.split(',').map(u => 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<LanguageBytes>) : ({} as LanguageBytes))
.catch(() => { hadFetchFailure = true; return {} as LanguageBytes; })
)
);

const langResults: LanguageBytes[] = await Promise.all(languageFetches);

cachedLanguageData = langResults.reduce<LanguageBytes>((acc, languages) => {
const result = langResults.reduce<LanguageBytes>((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);

Expand Down
Loading
Loading