From 6bccdbc12f2a53cc085b6f8f19ba3b3dec3d07f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:43:12 -0700 Subject: [PATCH] fix(tasks): cap advertised GitHub pages at the search result window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/renderer/src/components/TaskPage.tsx | 18 +++++++++++++++++- src/renderer/src/i18n/locales/en.json | 3 ++- src/renderer/src/store/slices/github.test.ts | 18 ++++++++++++++++++ src/renderer/src/store/slices/github.ts | 9 ++++++++- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 94eb63b148f..0aaede31648 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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) => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b74621aad7..a179add00c8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 91269b0b310..a9f30660f7e 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -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' diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 4f197565874..2651c772ea7 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -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 = (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 = (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 ) }