From 1f4cd0acb4f60e555a9c64122cc652f3ad7d77de Mon Sep 17 00:00:00 2001 From: pranayr710 <177966296+pranayr710@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:42:39 +0530 Subject: [PATCH] fix: guard paginated fetchers against non-array API responses fetchRepos, fetchContributors, fetchIssues and fetchPulls spread the response body directly: all.push(...data). Several GitHub API endpoints return a JSON object instead of an array under valid conditions - issues disabled on a repo, an empty repository, or a 204 No Content response - and the spread throws TypeError: data is not iterable, aborting the whole fetch instead of returning what was already collected. Treat a non-array page as an empty, final page: stop pagination there rather than crash. Fixes #225 --- src/services/github.js | 7 ++++ src/services/github.pagination.test.js | 57 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/services/github.pagination.test.js diff --git a/src/services/github.js b/src/services/github.js index a4180fa..190ad67 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -92,6 +92,10 @@ export async function fetchRepos(org, repoCount, pat) { for (let page = 1; page <= maxPages; page++) { const url = `https://api.github.com/orgs/${org}/repos?per_page=100&page=${page}&sort=updated` const data = await fetchWithCache(url, pat) + // The API returns a message/error object instead of an array for some + // valid states (disabled issues, empty repo, 204 No Content); treat that + // as an empty, final page rather than crashing on the spread below. + if (!Array.isArray(data)) break all.push(...data) if (data.length < 100) break } @@ -104,6 +108,7 @@ export async function fetchContributors(org, repo, pat) { for(let page = 1; page<=maxPages ; page++) { const url = `https://api.github.com/repos/${org}/${repo}/contributors?per_page=100&page=${page}` const data = await fetchWithCache(url, pat) + if (!Array.isArray(data)) break all.push(...data) if(data.length < 100) break } @@ -116,6 +121,7 @@ export async function fetchIssues(org, repo, pat) { for(let page = 1; page<=maxPages ; page++) { const url = `https://api.github.com/repos/${org}/${repo}/issues?state=all&per_page=100&page=${page}` const data = await fetchWithCache(url, pat) + if (!Array.isArray(data)) break all.push(...data) if(data.length < 100) break } @@ -128,6 +134,7 @@ export async function fetchPulls(org, repo, pat) { for(let page = 1; page<=maxPages ; page++) { const url = `https://api.github.com/repos/${org}/${repo}/pulls?state=all&per_page=100&page=${page}` const data = await fetchWithCache(url, pat) + if (!Array.isArray(data)) break all.push(...data) if(data.length < 100) break } diff --git a/src/services/github.pagination.test.js b/src/services/github.pagination.test.js new file mode 100644 index 0000000..1490728 --- /dev/null +++ b/src/services/github.pagination.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fetchRepos, fetchContributors, fetchIssues, fetchPulls } from './github' + +// GitHub returns a message/error object instead of an array for several +// valid states: issues disabled on a repo, an empty repository, or a 204 No +// Content response. `all.push(...data)` on one of those throws +// `TypeError: data is not iterable` and aborts the whole fetch. +function mockFetchOnce(body, status = 200) { + global.fetch = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: () => Promise.resolve(body) + }) +} + +describe('paginated fetchers guard against non-array API responses', () => { + beforeEach(() => { + // Bypass the IndexedDB-backed cache layer so each call reliably hits fetch. + global.indexedDB = undefined + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('fetchRepos returns [] instead of throwing when the API returns an error object', async () => { + mockFetchOnce({ message: 'Git Repository is empty.' }) + + await expect(fetchRepos('org', 0, null)).resolves.toEqual([]) + }) + + it('fetchContributors returns [] instead of throwing when issues/contributors are disabled', async () => { + mockFetchOnce({ message: 'Issues are disabled in this repository' }) + + await expect(fetchContributors('org', 'repo', null)).resolves.toEqual([]) + }) + + it('fetchIssues returns [] instead of throwing on a non-array payload', async () => { + mockFetchOnce({ message: 'Issues are disabled in this repository' }) + + await expect(fetchIssues('org', 'repo', null)).resolves.toEqual([]) + }) + + it('fetchPulls returns [] instead of throwing on a non-array payload', async () => { + mockFetchOnce({ message: 'Git Repository is empty.' }) + + await expect(fetchPulls('org', 'repo', null)).resolves.toEqual([]) + }) + + it('fetchRepos still collects normal array pages', async () => { + const repos = Array.from({ length: 3 }, (_, i) => ({ id: i })) + mockFetchOnce(repos) + + await expect(fetchRepos('org', 3, null)).resolves.toEqual(repos) + }) +})