Skip to content

Commit d120e7a

Browse files
authored
Merge pull request #21 from masonlet/feat/multi-source-stats
Feat/multi source stats
2 parents 4474754 + 04297a8 commit d120e7a

5 files changed

Lines changed: 61 additions & 30 deletions

File tree

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
GITHUB_USERNAME=your_username
1+
GITHUB_USERNAMES=your_username(s)
2+
GITHUB_ORGS=
23
IGNORED_REPOS=repo-name1,repo-name2,repo-name3

src/api/github.js

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,26 @@ export async function fetchLanguageData(useTestData = false) {
1313
if (cachedLanguageData && now - lastRefresh < REFRESH_INTERVAL)
1414
return cachedLanguageData;
1515

16-
const username = process.env.GITHUB_USERNAME;
17-
if(!username) throw new Error(`GITHUB_USERNAME environment variable is not set`);
16+
const usernames = process.env.GITHUB_USERNAMES?.split(',').map(u => u.trim()).filter(Boolean) || [];
17+
const orgs = process.env.GITHUB_ORGS?.split(',').map(o => o.trim()).filter(Boolean) || [];
1818

19-
const reposResponse = await fetch(`https://api.github.com/users/${username}/repos?per_page=100`);
20-
if(!reposResponse.ok)
21-
throw new Error(`GitHub API error: ${reposResponse.status} ${reposResponse.statusText}`);
19+
if(usernames.length === 0 && orgs.length === 0)
20+
throw new Error("At least one of GITHUB_USERNAMES or GITHUB_ORGS must be set");
2221

23-
const repos = await reposResponse.json();
24-
const ignored = process.env.IGNORED_REPOS?.split(',').map(name => name.trim()) || [];
22+
const fetchPromises = [
23+
...usernames.map(user => fetch(`https://api.github.com/users/${user}/repos?per_page=100`)),
24+
...orgs.map(org => fetch(`https://api.github.com/orgs/${org}/repos?per_page=100`))
25+
];
26+
27+
const responses = await Promise.all(fetchPromises);
28+
29+
for (const response of responses)
30+
if (!response.ok) throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
2531

32+
const repoArrays = await Promise.all(responses.map(r => r.json()));
33+
const repos = repoArrays.flat();
34+
35+
const ignored = process.env.IGNORED_REPOS?.split(',').map(name => name.trim()) || [];
2636
const filteredRepos = repos.filter(repo => !repo.fork && !ignored.includes(repo.name));
2737

2838
const languageFetches = filteredRepos.map(repo =>

tests/api/github.test.js

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ const languages = {
1515

1616
describe("fetchLanguageData", () => {
1717
beforeEach(() => {
18-
vi.stubEnv('GITHUB_USERNAME', 'testuser');
19-
vi.stubEnv('IGNORED_REPOS', 'ignored-repo');
18+
vi.stubEnv("GITHUB_USERNAMES", "testuser");
19+
vi.stubEnv("IGNORED_REPOS", "ignored-repo");
2020
global.fetch = vi.fn();
2121
vi.resetModules();
2222
resetCache();
@@ -33,14 +33,14 @@ describe("fetchLanguageData", () => {
3333
expect(global.fetch).not.toHaveBeenCalled();
3434
});
3535

36-
it("throws error when GITHUB_USERNAME env variable not set", async () => {
36+
it("throws error when GITHUB_USERNAMES env variable not set", async () => {
3737
vi.unstubAllEnvs();
38-
await expect(fetchLanguageData()).rejects.toThrow('GITHUB_USERNAME environment variable is not set');
38+
await expect(fetchLanguageData()).rejects.toThrow("At least one of GITHUB_USERNAMES or GITHUB_ORGS must be set");
3939
});
4040

4141
it("handles missing IGNORED_REPOS env variable", async () => {
4242
vi.unstubAllEnvs();
43-
vi.stubEnv('GITHUB_USERNAME', 'testuser');
43+
vi.stubEnv("GITHUB_USERNAMES", "testuser");
4444

4545
global.fetch
4646
.mockResolvedValueOnce({ ok: true, json: async () => repos })
@@ -64,17 +64,17 @@ describe("fetchLanguageData", () => {
6464
await fetchLanguageData();
6565

6666
expect(global.fetch).toHaveBeenCalledWith(
67-
'https://api.github.com/users/testuser/repos?per_page=100'
67+
"https://api.github.com/users/testuser/repos?per_page=100"
6868
);
6969
expect(global.fetch).toHaveBeenCalledWith(
70-
'https://api.github.com/repos/user/repo1/languages'
70+
"https://api.github.com/repos/user/repo1/languages"
7171
);
7272

7373
expect(global.fetch).not.toHaveBeenCalledWith(
74-
'https://api.github.com/repos/user/repo2/languages'
74+
"https://api.github.com/repos/user/repo2/languages"
7575
);
7676
expect(global.fetch).not.toHaveBeenCalledWith(
77-
'https://api.github.com/repos/user/ignored-repo/languages'
77+
"https://api.github.com/repos/user/ignored-repo/languages"
7878
);
7979
});
8080

@@ -97,10 +97,10 @@ describe("fetchLanguageData", () => {
9797
global.fetch.mockResolvedValueOnce({
9898
ok: false,
9999
status: 404,
100-
statusText: 'Not Found'
100+
statusText: "Not Found"
101101
});
102102

103-
await expect(fetchLanguageData()).rejects.toThrow('GitHub API error: 404 Not Found');
103+
await expect(fetchLanguageData()).rejects.toThrow("GitHub API error: 404 Not Found");
104104
});
105105

106106
it("caches results within refresh interval", async () => {
@@ -128,33 +128,53 @@ describe("fetchLanguageData", () => {
128128
const result = await fetchLanguageData();
129129
expect(result).toEqual({ Python: 500 });
130130
});
131+
132+
it("fetches from organizations", async () => {
133+
vi.unstubAllEnvs();
134+
vi.stubEnv("GITHUB_ORGS", "test-org");
135+
136+
const orgRepos = [
137+
{ name: "org-repo", fork: false, full_name: "test-org/org-repo" }
138+
];
139+
140+
global.fetch
141+
.mockResolvedValueOnce({ ok: true, json: async () => orgRepos })
142+
.mockResolvedValueOnce({ ok: true, json: async () => ({ TypeScript: 4000 }) })
143+
144+
const result = await fetchLanguageData();
145+
146+
expect(global.fetch).toHaveBeenCalledWith(
147+
"https://api.github.com/orgs/test-org/repos?per_page=100"
148+
);
149+
expect(result).toEqual({ TypeScript: 4000 });
150+
});
131151
});
132152

133153
describe("processLanguageData", () => {
134154
it("calculates percentages correctly", () => {
135155
const data = { JavaScript: 5000, Python: 3000, HTML: 2000 };
136156
const result = processLanguageData(data, 3);
137157
expect(result).toHaveLength(3);
138-
expect(result[0]).toEqual({ lang: 'JavaScript', pct: 50 });
139-
expect(result[1]).toEqual({ lang: 'Python', pct: 30 });
140-
expect(result[2]).toEqual({ lang: 'HTML', pct: 20 });
158+
expect(result[0]).toEqual({ lang: "JavaScript", pct: 50 });
159+
expect(result[1]).toEqual({ lang: "Python", pct: 30 });
160+
expect(result[2]).toEqual({ lang: "HTML", pct: 20 });
141161
});
142162

143163
it("sorts by percentage descending", () => {
144164
const data = { HTML: 1000, JavaScript: 5000, Python: 3000 };
145165
const result = processLanguageData(data, 3);
146166

147-
expect(result[0].lang).toBe('JavaScript');
148-
expect(result[1].lang).toBe('Python');
149-
expect(result[2].lang).toBe('HTML');
167+
expect(result[0].lang).toBe("JavaScript");
168+
expect(result[1].lang).toBe("Python");
169+
expect(result[2].lang).toBe("HTML");
150170
});
151171

152172
it("limits to count", () => {
153173
const data = { JavaScript: 5000, Python: 3000, HTML: 2000, CSS: 1000 };
154174
const result = processLanguageData(data, 2);
155175

156176
expect(result).toHaveLength(2);
157-
expect(result.map(l => l.lang)).toEqual(['JavaScript', 'Python']);
177+
expect(result.map(l => l.lang)).toEqual(["JavaScript", "Python"]);
158178
});
159179

160180
it("renormalizes percentages after slicing", () => {
@@ -166,6 +186,6 @@ describe("processLanguageData", () => {
166186
});
167187

168188
it("throws when no language data", () => {
169-
expect(() => processLanguageData({}, 5)).toThrow('No language data available');
189+
expect(() => processLanguageData({}, 5)).toThrow("No language data available");
170190
});
171191
});

tests/utils/params.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe("parseQueryParams", () => {
3838

3939
it("sanitizes title when provided", () => {
4040
const params = parseQueryParams({ title: `<scripts>alert("x")</script>` });
41-
expect(params.chartTitle).toBe(`&lt;scripts&gt;alert(&quot;x&quot;)&lt;/script&gt;`);
41+
expect(params.chartTitle).toBe("&lt;scripts&gt;alert(&quot;x&quot;)&lt;/script&gt;");
4242
});
4343

4444
it("clamps count between 1 and MAX_COUNT", () => {

tests/utils/sanitize.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ describe("sanitize", () => {
1111
});
1212

1313
it("escapes the criticial HTML characters", () => {
14-
expect(sanitize(`<>&"'`)).toBe(`&lt;&gt;&amp;&quot;&#39;`);
14+
expect(sanitize(`<>&"'`)).toBe("&lt;&gt;&amp;&quot;&#39;");
1515
});
1616

1717
it("leaves safe strings unchanged", () => {
1818
expect(sanitize("Hello world")).toBe("Hello world");
1919
});
2020

2121
it("escapes mixed content correctly", () => {
22-
expect(sanitize(`Hi <Mason> & "team"`)).toBe(`Hi &lt;Mason&gt; &amp; &quot;team&quot;`);
22+
expect(sanitize(`Hi <Mason> & "team"`)).toBe("Hi &lt;Mason&gt; &amp; &quot;team&quot;");
2323
});
2424
});

0 commit comments

Comments
 (0)