Extract GitHub search result window error pattern to shared constant

Extract the 1000-result window detection pattern to a single source of truth so
the classifier and consumer stay synchronized. The pattern is the only signal
separating a permanently unreachable page from a transient validation failure,
so drift or trimming silently demotes window 422s to generic failures and stops
capping the advertised page count (#11485).
This commit is contained in:
Jinjing
2026-08-02 11:32:35 -07:00
parent 689aa56b07
commit 950b3eb514
5 changed files with 31 additions and 8 deletions
+6 -4
View File
@@ -38,6 +38,7 @@ import {
__resetLocalGitConfigSignatureCacheForTests,
readLocalGitConfigSignature
} from './local-git-config-signature'
import { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN } from '../../shared/github-work-items-query-bounds'
describe('github owner/repo resolution', () => {
beforeEach(() => {
@@ -797,14 +798,15 @@ describe('gh error classification', () => {
})
// 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.
// GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN against this message (#11485) —
// trimming the raw stderr out of the validation_error copy, or drifting the
// pattern off GitHub's real wording, would silently downgrade every window
// 422 to a generic failure. The stderr stays verbatim so this pins both ends.
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)
expect(classified.message).toMatch(GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN)
})
})
+7 -2
View File
@@ -3211,7 +3211,12 @@ export default function TaskPage(): React.JSX.Element {
)
// Why: see buildSelectedReposKey — array-identity deps re-fire on every
// repos:changed even when the selection is unchanged.
// repos:changed even when the selection is unchanged. The context part is
// resolved as GitHub, but every provider-independent field (projectId,
// hostId, projectHostSetupId, repoId) is identical across providers, so the
// GitLab effect can key off this too — it passes no gitlabProjectRef, so its
// context carries no providerIdentity of its own. Thread a projectRef into
// that call and this key needs a GitLab-scoped part.
const selectedReposKey = useMemo(
() => buildSelectedReposKey(selectedRepos, (r) => getTaskPageRepoSourceContext(r, 'github')),
[selectedRepos]
@@ -4967,7 +4972,7 @@ export default function TaskPage(): React.JSX.Element {
return () => {
stale = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedReposKey encodes the only selectedRepos fields read above; keying off the array ref would re-run on every parent render.
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedReposKey covers every selectedRepos field read above (see its GitHub-scoped-context note); keying off the array ref would re-run on every parent render.
}, [taskSource, gitlabView, activeGitlabFilter, gitlabRefreshNonce, selectedReposKey])
// Why: Todos fetch has its own effect — different trigger (no chip filter) and data path (gl.todos is user-scoped, not repo-scoped).
@@ -1,4 +1,5 @@
export {
GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN,
GITHUB_WORK_ITEMS_QUERY_MAX_BYTES,
isGitHubWorkItemsQueryTooLarge
} from '../../../../shared/github-work-items-query-bounds'
+6 -2
View File
@@ -53,7 +53,10 @@ import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility
import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github'
import { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity'
import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from './github-cache-key'
import { isGitHubWorkItemsQueryTooLarge } from './github-work-items-query-bounds'
import {
GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN,
isGitHubWorkItemsQueryTooLarge
} from './github-work-items-query-bounds'
import { classifyGitHubUnavailable } from '../../../../shared/github-api-availability'
import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access'
import { translate } from '@/i18n/i18n'
@@ -2824,7 +2827,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
// Why: only the 1000-result-window 422 may drive the unreachable
// clamp; demote other validation errors so they read as failures.
errorTypes.push(
type === 'validation_error' && !/first 1000 search results/i.test(message)
type === 'validation_error' &&
!GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN.test(message)
? 'unknown'
: type
)
@@ -2,6 +2,17 @@ import { isClipboardTextByteLengthOverLimit } from './clipboard-text'
export const GITHUB_WORK_ITEMS_QUERY_MAX_BYTES = 8 * 1024
/**
* GitHub's Search API only pages through the first 1000 matches; beyond that it
* 422s with "Only the first 1000 search results are available". Matching that
* free-text wording is the ONLY signal separating a permanently unreachable page
* from a transient failure (#11485), so the phrase is pinned here rather than
* inlined: if GitHub rewords it, window 422s silently demote to generic
* failures and the advertised page count stops being capped.
* `gh-utils.test.ts` asserts the classified message still carries this phrase.
*/
export const GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN = /first 1000 search results/i
export function isGitHubWorkItemsQueryTooLarge(
query: string,
maxBytes = GITHUB_WORK_ITEMS_QUERY_MAX_BYTES