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.
This commit is contained in:
Jinjing
2026-06-24 23:22:16 -07:00
parent 67cbe3ead8
commit 2407ecc2c8
2 changed files with 39 additions and 23 deletions
+24 -14
View File
@@ -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 () => {
+15 -9
View File
@@ -287,8 +287,8 @@ async function getIssueTimelineItems(
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<GitHubIssueTimelineItem[]> {
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 []
}