fix(tasks): cap advertised GitHub pages at the search result window

GitHub's Search API rejects requests past its first-1000-results window
with HTTP 422, but totalPages was derived from the raw total_count, so
the pagination bar advertised pages that could never load and clicks on
them silently did nothing (#11485).

Cap per-repo advertised pages at floor(1000 / perRepoLimit), and when a
page load comes back empty, say so with a toast instead of ignoring the
click — clamping the advertised count only when no fetch threw, so
transient failures don't shrink the bar.
This commit is contained in:
Brennan Benson
2026-07-30 06:43:12 -07:00
parent ab665a3ce7
commit 6bccdbc12f
4 changed files with 45 additions and 3 deletions
+17 -1
View File
@@ -6165,7 +6165,7 @@ export default function TaskPage(): React.JSX.Element {
setPaginationLoading(true)
setLoadingTargetPage(target)
try {
const { items } = await fetchWorkItemsNextPage(
const { items, failedCount } = await fetchWorkItemsNextPage(
repoArgs,
githubPerRepoPageLimit,
githubPageSize,
@@ -6176,6 +6176,22 @@ export default function TaskPage(): React.JSX.Element {
return
}
if (items.length === 0) {
// Why: staying on the current page without saying so reads as a dead
// click (#11485). Empty with no thrown fetch means the target is past
// GitHub's reachable search window, so stop advertising it; thrown
// fetches may be transient, so keep the page count.
toast.error(
translate(
'auto.components.TaskPage.loadPageUnreachable',
'Page {{value0}} could not be loaded from GitHub.',
{ value0: String(target + 1) }
)
)
if (failedCount === 0) {
setCountedTotalPages((previous) =>
previous !== null && previous > target ? target : previous
)
}
return
}
setPages((previous) => {
+2 -1
View File
@@ -1889,7 +1889,8 @@
"75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.",
"noGithubSourceDetected": "No GitHub source detected for",
"noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved.",
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again."
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again.",
"loadPageUnreachable": "Page {{value0}} could not be loaded from GitHub."
},
"Terminal": {
"73768427cf": "Close",
@@ -6727,6 +6727,24 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
expect(result).toEqual({ totalCount: 101, totalPages: 3 })
})
it('caps advertised pages at the GitHub search result window', async () => {
const store = createTestStore()
mockApi.gh.countWorkItems.mockResolvedValueOnce(1170).mockResolvedValueOnce(1)
const result = await store.getState().countWorkItemsAcrossRepos(
[
{ repoId: 'large-repo', path: '/local/large' },
{ repoId: 'small-repo', path: '/local/small' }
],
'is:issue',
30
)
// 1170 results → 39 naive pages, but the Search API 422s past its
// 1000-result window; only floor(1000 / 30) = 33 pages are reachable (#11485).
expect(result).toEqual({ totalCount: 1171, totalPages: 33 })
})
it('rejects oversized work-item queries before cache keys or provider calls', async () => {
const store = createTestStore()
const secret = 'github-work-items-secret'
+8 -1
View File
@@ -655,6 +655,8 @@ const CHECKS_CACHE_TTL = 60_000 // 1 minute — checks change more frequently
const EMPTY_CHECKS_CACHE_TTL = 10_000
// Why: the work-item list is a browse surface, not a source of truth, so 60s staleness is fine (SWR keeps it current).
const WORK_ITEMS_CACHE_TTL = 60_000
// GitHub's Search API serves at most the first 1000 results; deeper requests 422.
const GITHUB_SEARCH_RESULT_WINDOW = 1000
// Why: long-lived (matches repos.ts) so the user has time to read + act on persist failures before the toast vanishes.
const ERROR_TOAST_DURATION = 60_000
@@ -2839,6 +2841,10 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
return { totalCount: 0, totalPages: 0 }
}
const normalizedLimit = Math.max(1, Math.floor(perRepoLimit))
// Why: GitHub's Search API rejects requests past its 1000-result window with
// HTTP 422, so pages whose range crosses it are unreachable — advertising
// them yields page clicks that can never navigate.
const maxReachablePages = Math.max(1, Math.floor(GITHUB_SEARCH_RESULT_WINDOW / normalizedLimit))
const counts = await Promise.all(
repos.map(async (r) => {
// Why: same stampede cap as item-fetch — without a slot a 90-repo selection fires 90 concurrent count IPCs before the main-side rate-limit guard sees the first 403.
@@ -2870,7 +2876,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
totalCount: counts.reduce((sum, count) => sum + count, 0),
// Why: repos advance independently by page, so take the max across repos — a sum/page-width undercounts when one repo owns most results.
totalPages: counts.reduce(
(maxPages, count) => Math.max(maxPages, Math.ceil(count / normalizedLimit)),
(maxPages, count) =>
Math.max(maxPages, Math.min(Math.ceil(count / normalizedLimit), maxReachablePages)),
0
)
}