Skip to content
Open
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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ These instructions have been tested on a clean machine to ensure a reliable setu

> **Note:** If the repository already includes `.env.example`, you only need to copy it to `.env`. Creating a new `.env.example` is only necessary if the file is missing.


5. **Start the development server:**
```bash
bun dev
Expand Down
91 changes: 81 additions & 10 deletions src/lib/api/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,21 +178,92 @@ describe('GitHub API Client', () => {
expect(mockFetch.mock.calls[0][0]).toContain('page=1');
});

it('paginates correctly until total count or maxIssues is reached', async () => {
const issueA = { number: 1, title: 'A', user: {}, labels: [], assignees: [], comments: 0 };
const issueB = { number: 2, title: 'B', user: {}, labels: [], assignees: [], comments: 0 };
it('paginates until all pages are consumed (bounded by search API 1000-result cap)', async () => {
// per_page=100, total_count=150 → exactly 2 pages are needed
const page1Items = Array.from({ length: 100 }, (_, i) => ({
number: i + 1,
title: `A${i + 1}`,
user: {},
labels: [],
assignees: [],
comments: 0,
}));
const page2Items = Array.from({ length: 50 }, (_, i) => ({
number: i + 101,
title: `B${i + 1}`,
user: {},
labels: [],
assignees: [],
comments: 0,
}));

// Return page 1 first, then page 2
mockFetch
.mockResolvedValueOnce(createMockResponse(200, { total_count: 2, items: [issueA] }))
.mockResolvedValueOnce(createMockResponse(200, { total_count: 2, items: [issueB] }));
.mockResolvedValueOnce(createMockResponse(200, { total_count: 150, items: page1Items }))
.mockResolvedValueOnce(createMockResponse(200, { total_count: 150, items: page2Items }));

// Max issues is 5, but total_count is 2, so it fetches 2 pages
const results = await searchIssues('owner', 'repo', 5);
const results = await searchIssues('owner', 'repo', 200);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(results).toHaveLength(2);
expect(results).toHaveLength(150);
expect(results[0].number).toBe(1);
expect(results[1].number).toBe(2);
expect(results[149].number).toBe(150);
});

it('keeps paginating when a page contains only pull requests', async () => {
// total_count includes PRs (search matches them too). Pages full of PRs
// must not stop pagination, otherwise issues beyond them are never fetched.
const prItem = {
number: 900,
title: 'PR',
user: {},
labels: [],
assignees: [],
comments: 0,
pull_request: {},
};
const issueItem = {
number: 42,
title: 'Real issue',
user: {},
labels: [],
assignees: [],
comments: 0,
};

// Page 1: 100 PRs (0 comments sort first), page 2: 100 PRs, page 3: 50 issues
mockFetch
.mockResolvedValueOnce(
createMockResponse(200, {
total_count: 250,
items: Array.from({ length: 100 }, () => prItem),
}),
)
.mockResolvedValueOnce(
createMockResponse(200, {
total_count: 250,
items: Array.from({ length: 100 }, () => prItem),
}),
)
.mockResolvedValueOnce(
createMockResponse(200, {
total_count: 250,
items: Array.from({ length: 50 }, () => issueItem),
}),
);

const results = await searchIssues('owner', 'repo', 50);
expect(mockFetch).toHaveBeenCalledTimes(3);
expect(results).toHaveLength(50);
expect(results[0].number).toBe(42);
});

it('does not request pages beyond the search API 1000-result cap', async () => {
mockFetch.mockResolvedValue(createMockResponse(200, { total_count: 5000, items: [] }));

await searchIssues('owner', 'repo', 1000);

// With per_page=100 and a 1000-result cap, at most 10 pages are requested
expect(mockFetch.mock.calls.length).toBe(10);
expect(mockFetch.mock.calls[9][0]).toContain('page=10');
});

it('stops pagination immediately when maxIssues is reached', async () => {
Expand Down
69 changes: 34 additions & 35 deletions src/lib/api/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ const rateLimitInfo: RateLimitInfo = {
reset: 0,
};

// GitHub's search API only returns the first 1000 results regardless of
// total_count; requests beyond page 10 (at per_page=100) return empty pages.
const MAX_SEARCH_RESULTS = 1000;

import { useAppStore } from '../../store/appStore';

function headers(): HeadersInit {
Expand Down Expand Up @@ -137,21 +141,19 @@ export async function searchIssues(

const issues = data.items
.filter((item) => !item.pull_request)
.map(
(item): Issue => ({
number: item.number,
title: item.title,
body: item.body,
user: item.user,
labels: item.labels,
assignees: item.assignees,
comments_count: item.comments,
created_at: item.created_at,
updated_at: item.updated_at,
html_url: item.html_url,
state: item.state,
}),
);
.map((item): Issue => ({
number: item.number,
title: item.title,
body: item.body,
user: item.user,
labels: item.labels,
assignees: item.assignees,
comments_count: item.comments,
created_at: item.created_at,
updated_at: item.updated_at,
html_url: item.html_url,
state: item.state,
}));

allIssues.push(...issues);

Expand All @@ -163,7 +165,8 @@ export async function searchIssues(
);

hasMore =
allIssues.length < data.total_count && issues.length > 0 && allIssues.length < maxIssues;
page * CONFIG.DEFAULT_PAGE_SIZE < Math.min(data.total_count, MAX_SEARCH_RESULTS) &&
allIssues.length < maxIssues;

page++;
}
Expand All @@ -190,15 +193,13 @@ export async function fetchIssueComments(

const data = await response.json();

return data.map(
(c: Record<string, unknown>): Comment => ({
id: c.id as number,
user: c.user as Comment['user'],
body: c.body as string,
created_at: c.created_at as string,
updated_at: c.updated_at as string,
}),
);
return data.map((c: Record<string, unknown>): Comment => ({
id: c.id as number,
user: c.user as Comment['user'],
body: c.body as string,
created_at: c.created_at as string,
updated_at: c.updated_at as string,
}));
}

export async function fetchIssueTimeline(
Expand All @@ -217,16 +218,14 @@ export async function fetchIssueTimeline(

const data: Record<string, unknown>[] = await response.json();

return data.map(
(e): TimelineEvent => ({
event: e.event as string,
created_at: e.created_at as string,
actor: e.actor as TimelineEvent['actor'],
source: e.source as TimelineEvent['source'],
commit_id: e.commit_id as string | undefined,
label: e.label as TimelineEvent['label'],
}),
);
return data.map((e): TimelineEvent => ({
event: e.event as string,
created_at: e.created_at as string,
actor: e.actor as TimelineEvent['actor'],
source: e.source as TimelineEvent['source'],
commit_id: e.commit_id as string | undefined,
label: e.label as TimelineEvent['label'],
}));
} catch {
return [];
}
Expand Down
Loading