fix(github): preserve outage attribution for combined queries

This commit is contained in:
Brennan Benson
2026-07-16 21:10:12 -07:00
parent 15f8973a79
commit c58eac518a
6 changed files with 42 additions and 16 deletions
@@ -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' },
+23 -13
View File
@@ -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<string, unknown>[])
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
}
const items = (JSON.parse(stdout) as Record<string, unknown>[])
.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
+1 -1
View File
@@ -9310,7 +9310,7 @@ export default function TaskPage(): React.JSX.Element {
// over the generic count banner below.
<div
role="alert"
className="border-b border-border/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200"
className="border-b border-border/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
{translate(
'auto.components.TaskPage.75a38d7df8',
@@ -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,
@@ -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',
+1 -1
View File
@@ -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