diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts index f9cf934e24e..7af6b124c34 100644 --- a/src/main/github/client-work-items-query-paging.test.ts +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -63,7 +63,8 @@ vi.mock('./gh-utils', () => ({ release: releaseMock, _resetOwnerRepoCache: vi.fn(), classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }), - classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }) + classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }), + classifyListPrsError: (stderr: string) => ({ type: 'unknown', message: stderr }) })) vi.mock('../git/runner', () => ({ @@ -266,6 +267,22 @@ describe('listWorkItems query paging', () => { expect(items.map((item) => item.number)).toEqual([4, 3]) }) + it('lifts a swallowed PR-side failure onto errors.prs instead of reading as end-of-data', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + // Non-availability failure (plain 403): swallowed into [], must still surface. + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403: Forbidden')) + + const envelope = await listWorkItems('/repo-root', 10, 'is:pr is:open', 2) + + expect(envelope.items).toEqual([]) + expect(envelope.errors?.issues).toBeUndefined() + expect(envelope.errors?.prs).toEqual({ + type: 'unknown', + message: expect.stringContaining('HTTP 403') + }) + }) + it('filters pull request rows out of issue Search API results', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 0226b2af42f..84119b3b2a2 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -55,7 +55,8 @@ vi.mock('./gh-utils', () => ({ release: releaseMock, _resetOwnerRepoCache: vi.fn(), classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }), - classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }) + classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }), + classifyListPrsError: (stderr: string) => ({ type: 'unknown', message: stderr }) })) vi.mock('../git/runner', () => ({ diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index ae7b6c414e4..8ce81b189c4 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -795,4 +795,16 @@ describe('gh error classification', () => { message: 'Issues are disabled on this repository.' }) }) + + // Why: the renderer detects the Search API 1000-result window by matching + // /first 1000 search results/i against this message (#11485) — trimming the + // raw stderr out of the validation_error copy would silently downgrade every + // window 422 to a generic failure. This pins the contract. + it('keeps the search-window phrase in validation_error list messages', () => { + const stderr = + 'Command failed: gh api --hostname github.com search/issues\nValidation Failed: Only the first 1000 search results are available (HTTP 422)' + const classified = classifyListIssuesError(stderr) + expect(classified.type).toBe('validation_error') + expect(classified.message).toMatch(/first 1000 search results/i) + }) }) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index e022e792f1a..69c8051b717 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -3823,6 +3823,9 @@ export default function TaskPage(): React.JSX.Element { // can't resurrect proven-unreachable pages, nor be pinned by a speculative // withdrawal (see deriveAdvertisedTotalPages). const [provenPageLimit, setProvenPageLimit] = useState(null) + // Why: synchronous mirror of countedTotalPages — the empty-page branch needs + // the committed value, not a click-time closure, and refs update immediately. + const countedTotalPagesRef = useRef(null) const fetchWorkItemsNextPage = useAppStore((s) => s.fetchWorkItemsNextPage) const countWorkItemsAcrossRepos = useAppStore((s) => s.countWorkItemsAcrossRepos) @@ -6228,23 +6231,24 @@ export default function TaskPage(): React.JSX.Element { { id: 'work-items-page-load-failed' } ) } else { - // Why: with a real count the clamp is refused, so without a toast - // the click would look dead — the count mis-advertised the page. - // countedTotalPages is a click-time closure read; worst case the - // toast decision is off in a race, which is benign. - if (countedTotalPages !== null && countedTotalPages > 0) { - toast.error( + // Why: with a real count the clamp is refused, so without feedback + // the click would look dead — the count over-advertised; nothing + // failed, so the copy stays neutral. The ref carries the committed + // count, immune to the click-time closure race. + const committedCount = countedTotalPagesRef.current + if (committedCount !== null && committedCount > 0) { + toast( translate( - 'auto.components.TaskPage.loadPageFailed', - 'Page {{value0}} could not be loaded from GitHub.', + 'auto.components.TaskPage.loadPageNoMoreResults', + 'No more results on page {{value0}}.', { value0: String(target + 1) } ), - { id: 'work-items-page-load-failed' } + { id: 'work-items-page-no-more-results' } ) } - setCountedTotalPages((previous) => - applyEmptyPageClamp(previous, { target, failedCount, errorTypes }) - ) + const next = applyEmptyPageClamp(committedCount, { target, failedCount, errorTypes }) + countedTotalPagesRef.current = next + setCountedTotalPages(next) } return } @@ -6270,7 +6274,6 @@ export default function TaskPage(): React.JSX.Element { paginationLoading, selectedRepos, currentPage, - countedTotalPages, appliedTaskSearch, fetchWorkItemsNextPage, githubPageSize, @@ -6354,6 +6357,7 @@ export default function TaskPage(): React.JSX.Element { setPages([page0]) setCurrentPage(0) setCountedTotalPages(null) + countedTotalPagesRef.current = null setProvenPageLimit(null) setTasksError(null) setFailedCount(0) // reset so a prior failure banner doesn't linger @@ -6462,6 +6466,7 @@ export default function TaskPage(): React.JSX.Element { // Why: the count overwrites unconditionally — proven window limits live // in provenPageLimit, so a late count can't be pinned by a speculative // end-of-data withdrawal, and can't resurrect proven-dead pages either. + countedTotalPagesRef.current = countedPages setCountedTotalPages(countedPages) } }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b34b6020c3..86bf19cc5d3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1891,7 +1891,8 @@ "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.", "loadPageUnreachable": "Page {{value0}} is beyond what GitHub search can return.", - "loadPageFailed": "Page {{value0}} could not be loaded from GitHub." + "loadPageFailed": "Page {{value0}} could not be loaded from GitHub.", + "loadPageNoMoreResults": "No more results on page {{value0}}." }, "Terminal": { "73768427cf": "Close", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e29d1ebcee4..f2f36c686c1 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1865,7 +1865,10 @@ "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": "La página {{value0}} está más allá de lo que la búsqueda de GitHub puede devolver.", + "loadPageFailed": "No se pudo cargar la página {{value0}} desde GitHub.", + "loadPageNoMoreResults": "No hay más resultados en la página {{value0}}." }, "Terminal": { "73768427cf": "Cerrar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8def5244244..d99aaaf04a5 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1865,7 +1865,10 @@ "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": "ページ {{value0}} は GitHub 検索が返せる範囲を超えています。", + "loadPageFailed": "GitHub からページ {{value0}} を読み込めませんでした。", + "loadPageNoMoreResults": "ページ {{value0}} にはこれ以上結果がありません。" }, "Terminal": { "73768427cf": "閉じる", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e1cc97e3a0d..17eb3d750f9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1865,7 +1865,10 @@ "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": "{{value0}} 페이지는 GitHub 검색이 반환할 수 있는 범위를 벗어났습니다.", + "loadPageFailed": "GitHub에서 {{value0}} 페이지를 불러오지 못했습니다.", + "loadPageNoMoreResults": "{{value0}} 페이지에 더 이상 결과가 없습니다." }, "Terminal": { "73768427cf": "닫기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 40b1c6b3cc4..2c463bdc869 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1865,7 +1865,10 @@ "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": "第 {{value0}} 页超出了 GitHub 搜索可返回的范围。", + "loadPageFailed": "无法从 GitHub 加载第 {{value0}} 页。", + "loadPageNoMoreResults": "第 {{value0}} 页没有更多结果。" }, "Terminal": { "73768427cf": "关闭",