mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user