Skip to content

Commit f4fca61

Browse files
authored
feat/github tokens (#30)
* feat: add GITHUB_TOKEN support for authenticated API requests * test: add tests for authenticated fetch headers * docs: add GITHUB_TOKEN to README and .env.example
1 parent 5f81d85 commit f4fca61

4 files changed

Lines changed: 35 additions & 15 deletions

File tree

.env.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
GITHUB_USERNAMES=your_username(s)
2-
GITHUB_ORGS=
3-
IGNORED_REPOS=repo-name1,repo-name2,repo-name3
2+
GITHUB_ORGS=your_org(s)
3+
IGNORED_REPOS=ignored_repo(s)
4+
GITHUB_TOKEN=

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Copy `.env.example` to `.env`, and update the variables.
9898
- `GITHUB_USERNAMES`: Comma-separated GitHub usernames to fetch repositories from.
9999
- `GITHUB_ORGS`: Optional comma-separated GitHub organization names to include.
100100
- `IGNORED_REPOS`: Optional comma-separated repo names to exclude from the chart.
101+
- `GITHUB_TOKEN`: Optional GitHub personal access token. Raises the API rate limit from 60 to 5000 requests/hour.
101102

102103
### Running Locally
103104
```bash
@@ -107,7 +108,7 @@ vercel dev
107108

108109
### Deployment
109110

110-
[![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)
111+
[![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)
111112

112113
> The default endpoint is /api/languages
113114

src/services/github.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ type Repo = {
1818
full_name: string;
1919
};
2020

21-
async function fetchAllRepos(url: string): Promise<Repo[]> {
22-
const repos: Repo[] = [];
21+
async function fetchAllRepos(url: string, options: RequestInit): Promise<Repo[]> {
2322
let nextUrl: string | null = url;
23+
const repos: Repo[] = [];
2424

2525
while (nextUrl) {
26-
const response = await fetch(nextUrl);
26+
const response = await fetch(nextUrl, options);
2727
if (!response.ok) throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
2828
repos.push(...await response.json() as Repo[]);
2929
nextUrl = parseNextLink(response.headers.get("Link"));
@@ -49,17 +49,20 @@ export async function fetchLanguageData(useTestData = false): Promise<LanguageBy
4949
"At least one of GITHUB_USERNAMES or GITHUB_ORGS must be set"
5050
);
5151

52+
const token: string | undefined = process.env["GITHUB_TOKEN"];
53+
const options: RequestInit = token ? { headers: { Authorization: `Bearer ${token}` } } : {};
54+
5255
const repoArrays = await Promise.all([
53-
...usernames.map(user => fetchAllRepos(`https://api.github.com/users/${user}/repos?per_page=100`)),
54-
...orgs.map( org => fetchAllRepos(`https://api.github.com/orgs/${org}/repos?per_page=100` ))
56+
...usernames.map(user => fetchAllRepos(`https://api.github.com/users/${user}/repos?per_page=100`, options)),
57+
...orgs.map( org => fetchAllRepos(`https://api.github.com/orgs/${org}/repos?per_page=100`, options))
5558
]);
5659
const repos = repoArrays.flat();
5760

5861
const ignored = process.env["IGNORED_REPOS"]?.split(',').map(name => name.trim()) || [];
5962
const filteredRepos = repos.filter(repo => !repo.fork && !ignored.includes(repo.name));
6063

6164
const languageFetches = filteredRepos.map(
62-
repo => fetch(`https://api.github.com/repos/${repo.full_name}/languages`).then(r => r.ok ? r.json() : {})
65+
repo => fetch(`https://api.github.com/repos/${repo.full_name}/languages`, options).then(r => r.ok ? r.json() : {})
6366
);
6467

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

tests/services/github.test.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,17 @@ describe("fetchLanguageData", () => {
7272
await fetchLanguageData();
7373

7474
expect(global.fetch).toHaveBeenCalledWith(
75-
"https://api.github.com/users/testuser/repos?per_page=100"
75+
"https://api.github.com/users/testuser/repos?per_page=100", {}
7676
);
7777
expect(global.fetch).toHaveBeenCalledWith(
78-
"https://api.github.com/repos/user/repo1/languages"
78+
"https://api.github.com/repos/user/repo1/languages", {}
7979
);
8080

8181
expect(global.fetch).not.toHaveBeenCalledWith(
82-
"https://api.github.com/repos/user/repo2/languages"
82+
"https://api.github.com/repos/user/repo2/languages", {}
8383
);
8484
expect(global.fetch).not.toHaveBeenCalledWith(
85-
"https://api.github.com/repos/user/ignored-repo/languages"
85+
"https://api.github.com/repos/user/ignored-repo/languages", {}
8686
);
8787
});
8888

@@ -149,7 +149,7 @@ describe("fetchLanguageData", () => {
149149
const result = await fetchLanguageData();
150150

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

171171
const result = await fetchLanguageData();
172-
expect(global.fetch).toHaveBeenCalledWith(page2Url);
172+
expect(global.fetch).toHaveBeenCalledWith(page2Url, {});
173173
expect(global.fetch).toHaveBeenCalledTimes(4);
174174
expect(result).toEqual({ JavaScript: 1000, Python: 500 });
175175
});
@@ -186,6 +186,21 @@ describe("fetchLanguageData", () => {
186186
expect(global.fetch).toHaveBeenCalledTimes(2);
187187
expect(result).toEqual({ Go: 300 });
188188
});
189+
190+
it("sends Authorization header when GITHUB_TOKEN is set", async () => {
191+
vi.stubEnv("GITHUB_TOKEN", "test-token");
192+
193+
mockFetch()
194+
.mockResolvedValueOnce(mockResponse([repos[0]]))
195+
.mockResolvedValueOnce(mockResponse(languages));
196+
197+
await fetchLanguageData();
198+
199+
expect(global.fetch).toHaveBeenCalledWith(
200+
"https://api.github.com/users/testuser/repos?per_page=100",
201+
{ headers: { Authorization: "Bearer test-token" } }
202+
);
203+
});
189204
});
190205

191206
describe("processLanguageData", () => {

0 commit comments

Comments
 (0)