From 2407ecc2c8decb5d6c902ab5496afca9b89073dd Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:22:16 -0700 Subject: [PATCH] Cap issue timeline pagination by supported activities Count only mapped, supported timeline items instead of raw REST events when checking the maximum item threshold. This prevents pagination from stopping early when pages contain many unsupported event types. --- src/main/github/work-item-details.test.ts | 38 ++++++++++++++--------- src/main/github/work-item-details.ts | 24 ++++++++------ 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 02779d7260b..5343c00813f 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -216,7 +216,7 @@ describe('getWorkItemDetails', () => { expect(details?.participants?.[0]?.login).toBe('issue-author') }) - it('caps issue timeline pagination to bounded drawer work', async () => { + it('caps issue timeline pagination by supported activity items', async () => { getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', type: 'issue', @@ -229,15 +229,17 @@ describe('getWorkItemDetails', () => { author: 'issue-author' }) getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) - const makeTimelinePage = (page: number): string => + const makeTimelineEvent = (page: number, index: number, event: string): string => + JSON.stringify({ + id: `${page}:${index}`, + event, + actor: { login: 'issue-author', avatar_url: 'https://x/y' }, + assignee: { login: `assignee-${page}-${index}` }, + created_at: '2026-04-01T00:00:00Z' + }) + const makeTimelinePage = (page: number, supportedCount: number): string => Array.from({ length: 100 }, (_, index) => - JSON.stringify({ - id: `${page}:${index}`, - event: 'assigned', - actor: { login: 'issue-author', avatar_url: 'https://x/y' }, - assignee: { login: `assignee-${page}-${index}` }, - created_at: '2026-04-01T00:00:00Z' - }) + makeTimelineEvent(page, index, index < supportedCount ? 'assigned' : 'subscribed') ).join('\n') ghExecFileAsyncMock .mockResolvedValueOnce({ @@ -254,13 +256,16 @@ describe('getWorkItemDetails', () => { } }) }) - .mockResolvedValueOnce({ stdout: makeTimelinePage(1) }) - .mockResolvedValueOnce({ stdout: makeTimelinePage(2) }) - .mockResolvedValueOnce({ stdout: makeTimelinePage(3) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(1, 0) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(2, 0) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(3, 10) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(4, 100) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(5, 100) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(6, 100) }) const details = await getWorkItemDetails('/repo-root', 923, 'issue') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(4) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(7) expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain( 'repos/acme/widgets/issues/923/timeline?per_page=100&page=1' ) @@ -270,12 +275,17 @@ describe('getWorkItemDetails', () => { expect(ghExecFileAsyncMock.mock.calls[3][0]).toContain( 'repos/acme/widgets/issues/923/timeline?per_page=100&page=3' ) + expect(ghExecFileAsyncMock.mock.calls[6][0]).toContain( + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=6' + ) expect( ghExecFileAsyncMock.mock.calls.some((call) => - call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=4') + call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=7') ) ).toBe(false) expect(details?.timelineItems).toHaveLength(300) + expect(details?.timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' }) + expect(details?.timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' }) }) it('falls back to REST + GraphQL when the collapsed issue query fails', async () => { diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 300e0820cb7..6029613fb3d 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -287,8 +287,8 @@ async function getIssueTimelineItems( ghOptions: ReturnType ): Promise { try { - const events: RestTimelineEvent[] = [] - for (let page = 1; events.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { + const items: GitHubIssueTimelineItem[] = [] + for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { const { stdout } = await ghExecFileAsync( [ 'api', @@ -300,18 +300,24 @@ async function getIssueTimelineItems( ], ghOptions ) - // Why: --jq emits compact NDJSON and explicit pages let us cap worst-case - // drawer work while still loading ordinary multi-page issue histories. + // Why: --jq emits compact NDJSON while explicit pages let us stop once + // supported activity reaches the drawer cap. const pageEvents = parseRestTimelineEventLines(stdout) - events.push(...pageEvents) + for (const event of pageEvents) { + const item = mapRestTimelineEvent(event) + if (!item) { + continue + } + items.push(item) + if (items.length === MAX_ISSUE_TIMELINE_ITEMS) { + break + } + } if (pageEvents.length < GITHUB_REST_PAGE_SIZE) { break } } - return events - .slice(0, MAX_ISSUE_TIMELINE_ITEMS) - .map(mapRestTimelineEvent) - .filter((item): item is GitHubIssueTimelineItem => item !== null) + return items } catch { return [] }