mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
fix(github): preserve outage attribution for combined queries
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user