fix(github): pin work-item list ordering to updated-desc so cursor pagination reaches every page (#8658)

* fix(github): pin work-item list ordering to updated-desc so cursor pagination reaches every page

The Tasks page paginates work items with an updatedAt cursor
(updated:<oldest-item), but the underlying gh calls never pinned a sort:
'gh issue list' defaults to created-desc and '--search' defaults to
best-match. Items created long ago but updated recently therefore never
appeared on any page — page 0 (created order) skipped them and every
later page excluded them via the cursor — so the pager advertised pages
the fetch chain could never reach, clicks on them clamped to the last
real page, and cross-page ordering was scrambled.

Append sort:updated-desc to every list/search invocation so the fetch
order matches the cursor field on the first and all subsequent pages.

Verified against a live 588-issue repo: the cursor chain previously
died around page 5; it now traverses 585/588 unique issues (the
remainder is the pre-existing strict '<' boundary edge for items
sharing the cursor's exact timestamp).

Fixes #8649

* fix(github): make work-item cursor pagination lossless at updatedAt boundaries

Builds on the sort-pin fix: switch the pagination cursor from strict
'updated:<' to inclusive 'updated:<=' so items sharing the boundary row's
exact updatedAt are no longer skipped between pages (the residual 3/588 edge
in #8649).

The inclusive bound re-fetches the boundary rows, so dedupe them by repoId+id
(a bare item.id like 'issue:9' collides across repos). Extract the page
accumulation out of the 12k-line TaskPage component into a pure, unit-tested
helper (accumulateWorkItemPages) that dedupes and backfills: it accumulates
fresh rows across fetches and emits uniform pageSize pages, so deduped pages
never shrink below the size totalPages (count / effectivePageSize) assumes —
which would otherwise strand the tail items and break the no-count degraded
pager.

Also hoist the updated-desc ordering into a named WORK_ITEM_LIST_SORT_QUALIFIER
constant so the cursor's ordering contract has one home.

Tradeoff: when per-repo fetch size equals pageSize, the boundary dedupe costs
one extra fetch per page; acceptable for interactive pagination and bounded by
the gh rate-limit guard. Persisting the cursor/buffer across calls is a
possible follow-up.

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
This commit is contained in:
moseoh 2026-07-14 14:37:46 +09:00 committed by GitHub
parent 8b86e7ead7
commit 7d8c4fdca1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 506 additions and 40 deletions

View File

@ -189,7 +189,9 @@ describe('listWorkItems', () => {
'--repo',
'acme/widgets',
'--assignee',
'@me'
'@me',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
@ -205,7 +207,9 @@ describe('listWorkItems', () => {
'--repo',
'acme/widgets',
'--assignee',
'@me'
'@me',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
@ -406,7 +410,9 @@ describe('listWorkItems', () => {
'acme/widgets',
'--state',
'open',
'--draft'
'--draft',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
@ -464,7 +470,9 @@ describe('listWorkItems', () => {
'--repo',
'acme/widgets',
'--state',
'merged'
'merged',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
@ -528,7 +536,7 @@ describe('listWorkItems', () => {
const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--state', 'closed', '--search', '-is:merged']),
expect.arrayContaining(['--state', 'closed', '--search', '-is:merged sort:updated-desc']),
{ cwd: '/repo-root' }
)
expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }])
@ -597,7 +605,7 @@ describe('listWorkItems', () => {
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'review-requested:@me']),
expect.arrayContaining(['--search', 'review-requested:@me sort:updated-desc']),
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith(
@ -606,6 +614,34 @@ describe('listWorkItems', () => {
)
})
it('pins list ordering to updated-desc so the updatedAt cursor pages consistently', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo-root', 10, 'is:issue is:open')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'sort:updated-desc']),
{ cwd: '/repo-root' }
)
})
it('combines the inclusive updatedAt cursor with updated-desc ordering on later pages', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo-root', 10, 'is:issue is:open', '2026-07-01T00:00:00Z')
// Why: the bound is inclusive (`<=`) so boundary items sharing the cursor's
// exact updatedAt aren't skipped; the renderer dedupes the re-fetched rows.
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'updated:<=2026-07-01T00:00:00Z sort:updated-desc']),
{ cwd: '/repo-root' }
)
})
it('returns open issues and PRs for the all-open preset query', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
@ -652,7 +688,9 @@ describe('listWorkItems', () => {
'--repo',
'acme/widgets',
'--state',
'open'
'open',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)
@ -667,7 +705,9 @@ describe('listWorkItems', () => {
'--repo',
'acme/widgets',
'--state',
'open'
'open',
'--search',
'sort:updated-desc'
],
{ cwd: '/repo-root' }
)

View File

@ -455,6 +455,12 @@ type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
const WORK_ITEM_ISSUE_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,assignees'
// Why: the Tasks pager slices pages on updatedAt, so every list/search fetch
// must return rows newest-updated-first for the cursor to advance correctly.
// This is the search-qualifier spelling of the same contract the REST list
// paths express as `sort=updated&direction=desc`; keep the two in sync (#8649).
const WORK_ITEM_LIST_SORT_QUALIFIER = 'sort:updated-desc'
const WORK_ITEM_PR_LIST_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
@ -996,11 +1002,13 @@ function buildWorkItemListArgs(args: {
if (excludeMergedFromClosed) {
searchParts.push('-is:merged')
}
// Why: cursor-based pagination. GitHub search supports updated:<DATE to
// fetch items older than the cursor. We use the oldest item's updatedAt
// from the previous page as the cursor.
// Why: cursor-based pagination. GitHub search supports updated:<=DATE to
// fetch items at or older than the cursor. We use the oldest item's updatedAt
// from the previous page as the cursor. The bound is inclusive (`<=`) so items
// sharing the boundary row's exact updatedAt aren't skipped between pages; the
// renderer dedupes the re-fetched boundary rows by id (#8649).
if (before) {
searchParts.push(`updated:<${before}`)
searchParts.push(`updated:<=${before}`)
}
if (kind === 'pr' && query.reviewRequested) {
searchParts.push(`review-requested:${query.reviewRequested}`)
@ -1011,9 +1019,14 @@ function buildWorkItemListArgs(args: {
if (query.freeText) {
searchParts.push(query.freeText)
}
if (searchParts.length > 0) {
out.push('--search', searchParts.join(' '))
}
// Why: pagination cursors slice on updatedAt, but `gh issue list` defaults
// to created-desc and `--search` defaults to best-match. `gh issue/pr list`
// has no --sort flag, so the only lever is the search query — which forces us
// to always emit --search. Without pinning the sort, recently-updated old
// items never appear on any page, so the pager advertises pages the fetch
// chain can never reach (#8649).
searchParts.push(WORK_ITEM_LIST_SORT_QUALIFIER)
out.push('--search', searchParts.join(' '))
return out
}

View File

@ -205,6 +205,7 @@ import {
type TaskPageRepoSourceState
} from '@/components/task-page-cache-selectors'
import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility'
import { accumulateWorkItemPages } from '@/components/task-page-work-item-pagination'
import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns'
import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linear-issue-attribute-filter-primary-team'
import {
@ -6280,32 +6281,32 @@ export default function TaskPage(): React.JSX.Element {
setPaginationLoading(true)
setLoadingTargetPage(target)
try {
let cursor = oldestItem.updatedAt
let loadedPages = pages.length
const newPages: GitHubWorkItem[][] = []
while (loadedPages <= target) {
const { items } = await fetchWorkItemsNextPage(
repoArgs,
PER_REPO_FETCH_LIMIT,
CROSS_REPO_DISPLAY_LIMIT,
q,
cursor
)
if (paginationGenerationRef.current !== requestGeneration) {
return
const result = await accumulateWorkItemPages({
existingPages: pages,
initialCursor: oldestItem.updatedAt,
targetPage: target,
// Why: uniform page size keeps deduped pages from shrinking below the
// size `totalPages` (count ÷ effectivePageSize) assumes — otherwise
// the per-page overlap loss would strand the tail items.
pageSize: effectivePageSize,
isCancelled: () => paginationGenerationRef.current !== requestGeneration,
fetchPage: async (cursor) => {
const { items } = await fetchWorkItemsNextPage(
repoArgs,
PER_REPO_FETCH_LIMIT,
CROSS_REPO_DISPLAY_LIMIT,
q,
cursor
)
return { items }
}
if (items.length === 0) {
break
}
newPages.push(items)
cursor = items.at(-1)!.updatedAt
loadedPages += 1
})
if (result.cancelled) {
return
}
if (newPages.length > 0) {
setPages((prev) => [...prev, ...newPages])
setCurrentPage(target < loadedPages ? target : loadedPages - 1)
if (result.newPages.length > 0) {
setPages((prev) => [...prev, ...result.newPages])
setCurrentPage(target < result.loadedPages ? target : result.loadedPages - 1)
}
} catch (err) {
console.error('Failed to load next page:', err)
@ -6316,7 +6317,14 @@ export default function TaskPage(): React.JSX.Element {
}
}
},
[paginationLoading, selectedRepos, pages, appliedTaskSearch, fetchWorkItemsNextPage]
[
paginationLoading,
selectedRepos,
pages,
appliedTaskSearch,
fetchWorkItemsNextPage,
effectivePageSize
]
)
useEffect(() => {

View File

@ -0,0 +1,280 @@
import { describe, expect, it, vi } from 'vitest'
import type { GitHubWorkItem } from '../../../shared/types'
import { accumulateWorkItemPages, workItemIdentity } from './task-page-work-item-pagination'
function item(repoId: string, id: string, updatedAt: string): GitHubWorkItem {
return {
id,
type: 'issue',
number: Number.parseInt(id.split(':')[1] ?? '0', 10),
title: id,
state: 'open',
url: `https://example.test/${id}`,
labels: [],
updatedAt,
author: null,
repoId
}
}
// Build a run of issues numbered `from`..`to` (inclusive) with strictly
// decreasing updatedAt, on repo `r`, so `at(-1)` is the oldest.
function run(r: string, from: number, to: number): GitHubWorkItem[] {
const out: GitHubWorkItem[] = []
for (let n = from; n <= to; n += 1) {
// Larger number => newer; encode as a descending timestamp so sort order is
// unambiguous and distinct.
const ts = `2026-01-01T00:00:${String(1000 - n).padStart(4, '0')}Z`
out.push(item(r, `issue:${n}`, ts))
}
return out
}
describe('workItemIdentity', () => {
it('qualifies the bare id with the repo so cross-repo collisions are distinct', () => {
expect(workItemIdentity(item('repo-a', 'issue:9', 't'))).toBe('repo-a:issue:9')
expect(workItemIdentity(item('repo-b', 'issue:9', 't'))).not.toBe(
workItemIdentity(item('repo-a', 'issue:9', 't'))
)
})
})
describe('accumulateWorkItemPages', () => {
it('drops the re-fetched boundary row that shares the previous page cursor', async () => {
const boundary = item('r', 'issue:2', '2026-07-02')
const existing = [[item('r', 'issue:1', '2026-07-03'), boundary]]
// Inclusive cursor re-returns issue:2 (boundary) then genuinely older rows,
// enough to fill a full page of size 2 after dedup.
const fetchPage = vi.fn().mockResolvedValue({
items: [boundary, item('r', 'issue:3', '2026-07-01'), item('r', 'issue:4', '2026-07-00')]
})
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: '2026-07-02',
targetPage: 1,
pageSize: 2,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
expect(result.newPages).toEqual([
[item('r', 'issue:3', '2026-07-01'), item('r', 'issue:4', '2026-07-00')]
])
expect(result.loadedPages).toBe(2)
expect(fetchPage).toHaveBeenCalledWith('2026-07-02')
})
it('backfills across fetches so a deduped page stays full and the tail stays reachable', async () => {
// Regression: pageSize 3. Page 0 = issues 1..3. Inclusive cursor re-fetches
// the boundary (issue:3), so a naive "one fetch = one page" scheme would emit
// a 2-item page and the count-derived totalPages would strand the tail.
const existing = [run('r', 1, 3)]
const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
const fetchPage = vi
.fn()
// <=cursor0 re-returns issue:3 (boundary) + 4,5 => 2 fresh, not yet a page.
.mockResolvedValueOnce({ items: run('r', 3, 5) })
// Backfill fetches again from issue:5's ts and gets 5 (boundary) + 6 => 1 fresh.
.mockResolvedValueOnce({ items: run('r', 5, 6) })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: cursor0,
targetPage: 1,
pageSize: 3,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
// One uniform full page of the 3 genuinely-new items 4,5,6 — nothing stranded.
expect(result.newPages).toEqual([run('r', 4, 6)])
expect(result.loadedPages).toBe(2)
expect(fetchPage).toHaveBeenCalledTimes(2)
expect(fetchPage).toHaveBeenNthCalledWith(2, run('r', 3, 5).at(-1)!.updatedAt)
})
it('does not confuse same-numbered items from different repos', async () => {
const existing = [[item('repo-a', 'issue:9', '2026-07-02')]]
// repo-b's issue:9 is a different item and must survive dedup.
const fetchPage = vi
.fn()
.mockResolvedValue({ items: [item('repo-b', 'issue:9', '2026-07-01')] })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: '2026-07-02',
targetPage: 1,
pageSize: 1,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
expect(result.newPages).toEqual([[item('repo-b', 'issue:9', '2026-07-01')]])
expect(result.loadedPages).toBe(2)
})
it('flushes a short final page when the source is exhausted mid-page', async () => {
const existing = [run('r', 1, 3)]
const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
const fetchPage = vi
.fn()
// 2 fresh rows, short of pageSize 3...
.mockResolvedValueOnce({ items: run('r', 3, 5) })
// ...then the source is exhausted.
.mockResolvedValueOnce({ items: [] })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: cursor0,
targetPage: 1,
pageSize: 3,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
// Short final page rather than dropping issues 4,5.
expect(result.newPages).toEqual([run('r', 4, 5)])
expect(result.loadedPages).toBe(2)
})
it('stops (flushing progress) when a full page yields nothing new', async () => {
const existing = [run('r', 1, 3)]
const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
// First fetch adds issues 4,5 (2 fresh); second fetch re-returns only
// already-seen rows (a >pageSize same-timestamp run) — no forward progress.
const fetchPage = vi
.fn()
.mockResolvedValueOnce({ items: run('r', 3, 5) })
.mockResolvedValueOnce({ items: run('r', 4, 5) })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: cursor0,
targetPage: 5,
pageSize: 3,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
// The 2 buffered fresh rows are flushed rather than lost.
expect(result.newPages).toEqual([run('r', 4, 5)])
expect(fetchPage).toHaveBeenCalledTimes(2)
})
it('chains full pages until the target page is reached', async () => {
const existing = [run('r', 1, 2)]
const cursor0 = run('r', 1, 2).at(-1)!.updatedAt
const fetchPage = vi
.fn()
.mockResolvedValueOnce({ items: run('r', 2, 4) }) // boundary 2 + 3,4
.mockResolvedValueOnce({ items: run('r', 4, 6) }) // boundary 4 + 5,6
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: cursor0,
targetPage: 2,
pageSize: 2,
fetchPage,
isCancelled: () => false
})
expect(result.cancelled).toBe(false)
if (result.cancelled) {
return
}
expect(result.newPages).toEqual([run('r', 3, 4), run('r', 5, 6)])
expect(result.loadedPages).toBe(3)
})
it('stops at an empty page (source exhausted) with no buffered rows', async () => {
const existing = [[item('r', 'issue:1', '2026-07-02')]]
const fetchPage = vi.fn().mockResolvedValue({ items: [] })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: '2026-07-02',
targetPage: 3,
pageSize: 3,
fetchPage,
isCancelled: () => false
})
expect(result).toEqual({ cancelled: false, newPages: [], loadedPages: 1 })
})
it('recovers leftover rows discarded at the target boundary on the next call', async () => {
const pageSize = 3
// Call 1: page0 = 1..3, target page 1. The fetch returns more than one
// page of fresh rows; issue 7 is past the emitted page and gets discarded
// (it belongs to page 2, which the user hasn't requested yet).
const existing1 = [run('r', 1, 3)]
const fetch1 = vi.fn().mockResolvedValueOnce({ items: run('r', 3, 7) }) // boundary 3 + 4,5,6,7
const call1 = await accumulateWorkItemPages({
existingPages: existing1,
initialCursor: run('r', 1, 3).at(-1)!.updatedAt,
targetPage: 1,
pageSize,
fetchPage: fetch1,
isCancelled: () => false
})
expect(call1.cancelled).toBe(false)
if (call1.cancelled) {
return
}
expect(call1.newPages).toEqual([run('r', 4, 6)]) // 7 discarded
// Call 2: page1 is now on screen. The inclusive cursor re-fetches the
// boundary (6, deduped) plus the discarded 7 and beyond — 7 must reappear
// exactly once, in order, with no duplicate of 4..6.
const call2 = await accumulateWorkItemPages({
existingPages: [...existing1, ...call1.newPages],
initialCursor: call1.newPages.at(-1)!.at(-1)!.updatedAt, // ts(6)
targetPage: 2,
pageSize,
fetchPage: vi.fn().mockResolvedValueOnce({ items: run('r', 6, 9) }), // boundary 6 + 7,8,9
isCancelled: () => false
})
expect(call2.cancelled).toBe(false)
if (call2.cancelled) {
return
}
expect(call2.newPages).toEqual([run('r', 7, 9)]) // 7 recovered, deduped, in order
})
it('reports cancellation and discards fetched pages when superseded', async () => {
const existing = [[item('r', 'issue:1', '2026-07-02')]]
const fetchPage = vi.fn().mockResolvedValue({ items: [item('r', 'issue:2', '2026-07-01')] })
const result = await accumulateWorkItemPages({
existingPages: existing,
initialCursor: '2026-07-02',
targetPage: 1,
pageSize: 1,
fetchPage,
isCancelled: () => true
})
expect(result).toEqual({ cancelled: true })
})
})

View File

@ -0,0 +1,125 @@
import type { GitHubWorkItem } from '../../../shared/types'
/**
* Cross-repo Tasks pagination is cursor-based on `updatedAt`: each page's oldest
* row seeds the next fetch as `updated:<=<cursor>`. The bound is inclusive so
* items sharing the boundary row's exact timestamp aren't skipped between pages
* (#8649) which means the boundary rows come back on the next fetch and must
* be deduped by identity here.
*
* Because dedup removes the re-fetched overlap, a single fetch no longer yields
* a full page. If we emitted each deduped fetch as its own page, pages 1+ would
* be one row short of `pageSize` while `totalPages` (count ÷ pageSize) still
* assumed full pages stranding the tail items. So we backfill: accumulate
* fresh rows across as many fetches as needed and emit uniform `pageSize` pages,
* flushing a short final page only when the source is exhausted.
*
* Kept as a pure function (out of the TaskPage component) so the dedup +
* cursor-advance + fixed-page contract is unit-testable without a DOM.
*
* Tradeoff: when the per-repo fetch size equals `pageSize` the boundary dedup
* costs one extra fetch per page (and re-fetches the sub-page leftover on the
* next advance). Acceptable for interactive pagination and bounded by the gh
* rate-limit guard; persisting the cursor/buffer across calls to avoid the
* re-fetch is a possible follow-up.
*/
// Why: `item.id` (e.g. "issue:9") is only unique within a repo — two selected
// repos can carry the same bare id. Key dedup on repo + id, matching the row
// key the table renders with.
export function workItemIdentity(item: Pick<GitHubWorkItem, 'id' | 'repoId'>): string {
return `${item.repoId}:${item.id}`
}
export type WorkItemPageFetchResult = { items: GitHubWorkItem[] }
export type AccumulateWorkItemPagesArgs = {
/** Pages already on screen; their items seed the dedup set. */
existingPages: readonly GitHubWorkItem[][]
/** updatedAt of the oldest currently-loaded row — the first fetch cursor. */
initialCursor: string
/** 0-indexed page the user is trying to reach. */
targetPage: number
/** Uniform display page size (the component's effectivePageSize). */
pageSize: number
/** Fetch one page for the given inclusive cursor. */
fetchPage: (cursor: string) => Promise<WorkItemPageFetchResult>
/** Returns true if the request was superseded and results should be dropped. */
isCancelled: () => boolean
}
export type AccumulateWorkItemPagesResult =
| { cancelled: true }
| {
cancelled: false
/** Freshly fetched, deduped, uniform-sized pages to append. */
newPages: GitHubWorkItem[][]
/** Total loaded page count after appending (existing + new). */
loadedPages: number
}
/**
* Fetch pages until `targetPage` is reached (or the source is exhausted),
* deduping re-fetched boundary rows and emitting uniform `pageSize` pages so
* every item lands on exactly one page and page sizes stay consistent with the
* count-derived `totalPages`.
*/
export async function accumulateWorkItemPages(
args: AccumulateWorkItemPagesArgs
): Promise<AccumulateWorkItemPagesResult> {
const { existingPages, initialCursor, targetPage, pageSize, fetchPage, isCancelled } = args
const seen = new Set<string>()
for (const page of existingPages) {
for (const item of page) {
seen.add(workItemIdentity(item))
}
}
let cursor = initialCursor
let loadedPages = existingPages.length
const newPages: GitHubWorkItem[][] = []
// Fresh rows not yet emitted as a page, held until they fill `pageSize`.
let buffer: GitHubWorkItem[] = []
const emitFullPages = (): void => {
while (buffer.length >= pageSize && loadedPages <= targetPage) {
newPages.push(buffer.slice(0, pageSize))
buffer = buffer.slice(pageSize)
loadedPages += 1
}
}
while (loadedPages <= targetPage) {
const { items } = await fetchPage(cursor)
if (isCancelled()) {
return { cancelled: true }
}
if (items.length === 0) {
break
}
const fresh = items.filter((item) => !seen.has(workItemIdentity(item)))
// Advance from the raw fetch, not the deduped rows, so the cursor tracks
// real data. If a full page yields nothing new the cursor can't move past
// this timestamp (a rare >pageSize same-timestamp run), so stop rather than
// re-fetch the same window forever.
cursor = items.at(-1)!.updatedAt
if (fresh.length === 0) {
break
}
for (const item of fresh) {
seen.add(workItemIdentity(item))
}
buffer.push(...fresh)
emitFullPages()
}
// Source exhausted (or stalled) with a partial page still buffered: emit it as
// a short final page so those items remain reachable.
if (buffer.length > 0 && loadedPages <= targetPage) {
newPages.push(buffer)
loadedPages += 1
}
return { cancelled: false, newPages, loadedPages }
}