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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
GITHUB_USERNAMES=your_username(s)
GITHUB_ORGS=
IGNORED_REPOS=repo-name1,repo-name2,repo-name3
GITHUB_ORGS=your_org(s)
IGNORED_REPOS=ignored_repo(s)
GITHUB_TOKEN=
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ 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.
- `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 @@ -107,7 +108,7 @@ vercel dev

### Deployment

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/masonlet/github-top-languages&env=GITHUB_USERNAMES,IGNORED_REPOS&envDescription[GITHUB_USERNAMES]=Comma-separated%20GitHub%20usernames&envDescription[IGNORED_REPOS]=Optional%20comma-separated%20repos%20to%20exclude)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/masonlet/github-top-languages&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

Expand Down
15 changes: 9 additions & 6 deletions src/services/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ type Repo = {
full_name: string;
};

async function fetchAllRepos(url: string): Promise<Repo[]> {
const repos: Repo[] = [];
async function fetchAllRepos(url: string, options: RequestInit): Promise<Repo[]> {
let nextUrl: string | null = url;
const repos: Repo[] = [];

while (nextUrl) {
const response = await fetch(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[]);
nextUrl = parseNextLink(response.headers.get("Link"));
Expand All @@ -49,17 +49,20 @@ export async function fetchLanguageData(useTestData = false): Promise<LanguageBy
"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`)),
...orgs.map( org => fetchAllRepos(`https://api.github.com/orgs/${org}/repos?per_page=100` ))
...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))
]);
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`).then(r => r.ok ? r.json() : {})
repo => fetch(`https://api.github.com/repos/${repo.full_name}/languages`, options).then(r => r.ok ? r.json() : {})
);

const langResults: LanguageBytes[] = await Promise.all(languageFetches);
Expand Down
27 changes: 21 additions & 6 deletions tests/services/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,17 @@ describe("fetchLanguageData", () => {
await fetchLanguageData();

expect(global.fetch).toHaveBeenCalledWith(
"https://api.github.com/users/testuser/repos?per_page=100"
"https://api.github.com/users/testuser/repos?per_page=100", {}
);
expect(global.fetch).toHaveBeenCalledWith(
"https://api.github.com/repos/user/repo1/languages"
"https://api.github.com/repos/user/repo1/languages", {}
);

expect(global.fetch).not.toHaveBeenCalledWith(
"https://api.github.com/repos/user/repo2/languages"
"https://api.github.com/repos/user/repo2/languages", {}
);
expect(global.fetch).not.toHaveBeenCalledWith(
"https://api.github.com/repos/user/ignored-repo/languages"
"https://api.github.com/repos/user/ignored-repo/languages", {}
);
});

Expand Down Expand Up @@ -149,7 +149,7 @@ describe("fetchLanguageData", () => {
const result = await fetchLanguageData();

expect(global.fetch).toHaveBeenCalledWith(
"https://api.github.com/orgs/test-org/repos?per_page=100"
"https://api.github.com/orgs/test-org/repos?per_page=100", {}
);
expect(result).toEqual({ TypeScript: 4000 });
});
Expand All @@ -169,7 +169,7 @@ describe("fetchLanguageData", () => {
.mockResolvedValueOnce(mockResponse({ Python: 500 }));

const result = await fetchLanguageData();
expect(global.fetch).toHaveBeenCalledWith(page2Url);
expect(global.fetch).toHaveBeenCalledWith(page2Url, {});
expect(global.fetch).toHaveBeenCalledTimes(4);
expect(result).toEqual({ JavaScript: 1000, Python: 500 });
});
Expand All @@ -186,6 +186,21 @@ describe("fetchLanguageData", () => {
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(result).toEqual({ Go: 300 });
});

it("sends Authorization header when GITHUB_TOKEN is set", async () => {
vi.stubEnv("GITHUB_TOKEN", "test-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 test-token" } }
);
});
});

describe("processLanguageData", () => {
Expand Down
Loading