diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index e8132c5b797..3cc68232356 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -244,6 +244,21 @@ describe('GitHub issue source split', () => { } ) + it('propagates an outage when both sides of a combined query are unavailable', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) + ghExecFileAsyncMock + .mockRejectedValueOnce(new Error('HTTP 503: Service Unavailable')) + .mockRejectedValueOnce(new Error('HTTP 502: Bad Gateway')) + + await expect(listWorkItems('/repo-root', 10, 'is:open')).rejects.toThrow( + 'HTTP 503: Service Unavailable' + ) + + // Classification must reuse the issue and PR requests, not retry the outage. + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + it("uses upstream for recent PRs when preference='upstream'", async () => { resolveIssueSourceMock.mockResolvedValueOnce({ source: { owner: 'stablyai', repo: 'orca' }, diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 26d629aadb1..a2042d4b373 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -1270,6 +1270,9 @@ async function listQueriedWorkItems( query.reviewedBy !== null const issueScope = query.scope !== 'pr' && !hasPrOnlyFilter const prScope = query.scope !== 'issue' + let successfulRequestCount = 0 + let nonAvailabilityFailureCount = 0 + let availabilityError: unknown // Why: run the issue and PR fetches in parallel but surface the // issue-side error separately so the IPC envelope can carry it up. PR-side @@ -1292,17 +1295,17 @@ async function listQueriedWorkItems( const { stdout } = issueOwnerRepo ? await ghExecFileAsync(request.args, ghOptions) : await ghCwdResolvedExec(repoContext, request.args, ghOptions) - return { - items: (JSON.parse(stdout) as Record[]) - .filter((item) => !('pull_request' in item)) - .map(mapIssueWorkItem) - } + const items = (JSON.parse(stdout) as Record[]) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) + successfulRequestCount += 1 + return { items } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) - if (!prScope && classifyGitHubUnavailable(stderr)) { - // Why: a scoped issue query has no successful side to preserve. Let the - // cross-repo caller attribute the empty result to GitHub instead. - throw err + if (classifyGitHubUnavailable(stderr)) { + availabilityError ??= err + } else { + nonAvailabilityFailureCount += 1 } return { items: [], issuesError: classifyListIssuesError(stderr) } } @@ -1330,6 +1333,7 @@ async function listQueriedWorkItems( .slice(request.offset, request.offset + limit) .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, ghOptions) + successfulRequestCount += 1 if (query.state === 'closed') { return hydrated.filter((item) => item.state !== 'merged') } @@ -1337,16 +1341,22 @@ async function listQueriedWorkItems( } catch (err) { console.warn('listQueriedWorkItems PRs partial failure:', err) const stderr = err instanceof Error ? err.message : String(err) - if (!issueScope && classifyGitHubUnavailable(stderr)) { - // Why: Tasks scopes every query to one work-item kind. Propagate an - // outage instead of converting the only failed source into an empty list. - throw err + if (classifyGitHubUnavailable(stderr)) { + availabilityError ??= err + } else { + nonAvailabilityFailureCount += 1 } return [] } })() const [issueResult, prItems] = await Promise.all([issueFetch, prFetch]) + if (availabilityError && successfulRequestCount === 0 && nonAvailabilityFailureCount === 0) { + // Why: combined queries normally preserve either successful half. When + // every attempted half hit the same availability class, propagate one of + // those existing failures so Tasks can distinguish an outage from no data. + throw availabilityError + } return { items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit), issuesError: issueResult.issuesError diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 6add53d1625..e01c9727426 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -9310,7 +9310,7 @@ export default function TaskPage(): React.JSX.Element { // over the generic count banner below.
{translate( 'auto.components.TaskPage.75a38d7df8', diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index fb32a847941..e735e130004 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1496,7 +1496,7 @@ function buildMobileBrowserTab( // back through ?? would resurrect a stale workspace-level error. loadError: activePage ? activePage.loadError : workspace.loadError, certificateFailure: activePage - ? (state.browserCertificateFailuresByPageId?.[activePage.id] ?? null) + ? (state.browserCertificateFailuresByPageId[activePage.id] ?? null) : null, color: unifiedTab?.color ?? null, isPinned: unifiedTab?.isPinned === true, diff --git a/src/shared/github-api-availability.test.ts b/src/shared/github-api-availability.test.ts index df34de85940..6e0ec633ef4 100644 --- a/src/shared/github-api-availability.test.ts +++ b/src/shared/github-api-availability.test.ts @@ -16,6 +16,7 @@ describe('classifyGitHubUnavailable', () => { it('classifies transport failures as network', () => { for (const message of [ 'request to https://api.github.com failed, reason: getaddrinfo ENOTFOUND api.github.com', + 'error connecting to api.github.com\ncheck your internet connection or GitHub status', 'dial tcp: lookup api.github.com: no such host', 'connect ETIMEDOUT 140.82.112.5:443', 'TimeoutError: request aborted', diff --git a/src/shared/github-api-availability.ts b/src/shared/github-api-availability.ts index 83d59ae9fed..1f9ae6694da 100644 --- a/src/shared/github-api-availability.ts +++ b/src/shared/github-api-availability.ts @@ -23,7 +23,7 @@ const SERVER_ERROR_PATTERN = // both Node (ENOTFOUND/ECONNRESET) and the gh Go client ("dial tcp", "i/o // timeout", "no such host") shapes. const NETWORK_PATTERN = - /timeout|\btimed out\b|\bno such host\b|could not resolve host|could not resolve to a host|\bnetwork(?:error| (?:error|unavailable|unreachable|request failed))\b|\bconnection (?:refused|reset)\b|\bdial tcp\b|\bi\/o timeout\b|\bfetch failed\b|\bsocket hang up\b|ENOTFOUND|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH/i + /timeout|\btimed out\b|\bno such host\b|could not resolve host|could not resolve to a host|\bnetwork(?:error| (?:error|unavailable|unreachable|request failed))\b|\bconnection (?:refused|reset)\b|\berror connecting to\b|\bfailed to connect to\b|\bdial tcp\b|\bi\/o timeout\b|\bfetch failed\b|\bsocket hang up\b|ENOTFOUND|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH/i /** * Classify a gh/Octokit error message as a GitHub-reachability problem, or