fix(github): preserve Tasks outage attribution

This commit is contained in:
Brennan Benson
2026-07-16 19:16:36 -07:00
parent 9b82183d4c
commit eba96b6e10
5 changed files with 81 additions and 15 deletions
@@ -228,6 +228,22 @@ describe('GitHub issue source split', () => {
)
})
it.each(['is:issue', 'is:pr'])(
'propagates GitHub outages for scoped %s queries',
async (query) => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 503: Service Unavailable'))
await expect(listWorkItems('/repo-root', 10, query)).rejects.toThrow(
'HTTP 503: Service Unavailable'
)
// The outage signal reuses the failed request; it must not add retry subprocesses.
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
}
)
it("uses upstream for recent PRs when preference='upstream'", async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: { owner: 'stablyai', repo: 'orca' },
+11
View File
@@ -1288,6 +1288,11 @@ async function listQueriedWorkItems(
}
} 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
}
return { items: [], issuesError: classifyListIssuesError(stderr) }
}
})()
@@ -1320,6 +1325,12 @@ async function listQueriedWorkItems(
return hydrated
} 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
}
return []
}
})()
+3 -3
View File
@@ -3788,9 +3788,9 @@ export default function TaskPage(): React.JSX.Element {
// rejections populate tasksError instead — the two are mutually exclusive so
// a successful-with-partial-failure read and a hard-reject don't double-show.
const [failedCount, setFailedCount] = useState(0)
// Why: when the whole list fails because GitHub is unreachable (outage /
// network / rate limit), attribute it to GitHub instead of the vague "N of M
// projects failed to load" — an outage shouldn't read as an Orca bug.
// Why: when every selected refresh fails because GitHub is unreachable
// (outage / network / rate limit), attribute it to GitHub instead of making
// an empty or stale cached list look current.
const [githubUnavailable, setGithubUnavailable] = useState(false)
const [taskRefreshNonce, setTaskRefreshNonce] = useState(0)
// Why: the fetch effect uses this to detect when a nonce bump is from the
@@ -6322,6 +6322,45 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
}
})
it('flags githubUnavailable while serving stale cached rows after a failed refresh', async () => {
const store = createTestStore()
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const item = {
type: 'pr',
number: 8,
title: 'Cached PR',
url: 'https://example.test/8',
updatedAt: '2026-05-21T00:00:00Z'
} as GitHubWorkItem
mockApi.gh.listWorkItems
.mockResolvedValueOnce({
items: [item],
sources: {
issues: null,
prs: { owner: 'up', repo: 'r' },
originCandidate: { owner: 'up', repo: 'r' },
upstreamCandidate: null
}
})
.mockRejectedValueOnce(new Error('HTTP 503: Service Unavailable'))
try {
const repos = [{ repoId: 'github-repo', path: '/server/github-repo' }]
await store.getState().fetchWorkItemsAcrossRepos(repos, 24, 100, '')
const result = await store
.getState()
.fetchWorkItemsAcrossRepos(repos, 24, 100, '', { force: true })
expect(result.items).toEqual([{ ...item, repoId: 'github-repo' }])
expect(result.failedCount).toBe(0)
expect(result.githubUnavailable).toBe(true)
expect(mockApi.gh.listWorkItems).toHaveBeenCalledTimes(2)
} finally {
consoleWarn.mockRestore()
}
})
it('ignores an ineligible SSH repo when every GitHub source is unavailable', async () => {
const store = createTestStore()
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+12 -12
View File
@@ -2037,10 +2037,10 @@ export type GitHubSlice = {
* served from stale cache on rejection is NOT counted as failed — matching
* the single-repo behavior of quietly serving stale data.
*
* `githubUnavailable` is true when every selected GitHub source failed (with
* no cache) because GitHub was unreachable/unavailable (5xx outage, network,
* or rate limit). The caller uses it to attribute the empty list to a global
* GitHub availability failure instead of the generic partial-failure banner.
* `githubUnavailable` is true when every selected GitHub source refresh failed
* because GitHub was unreachable/unavailable (5xx outage, network, or rate
* limit), even when stale cache data remains available. The caller uses it to
* attribute the stale/empty list instead of showing a normal-looking result.
*/
fetchWorkItemsAcrossRepos: (
repos: {
@@ -2865,6 +2865,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
const state = get()
let failedCount = 0
let requestFailureCount = 0
let unavailableFailureCount = 0
let skippedSourceCount = 0
const perProjectResults = await Promise.all(
@@ -2885,6 +2886,10 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
skippedSourceCount += 1
return [] as GitHubWorkItem[]
}
requestFailureCount += 1
if (classifyGitHubUnavailable(err instanceof Error ? err.message : String(err))) {
unavailableFailureCount += 1
}
const key =
r.sourceContext?.provider === 'github'
? workItemsCacheKey(
@@ -2901,11 +2906,6 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
console.warn(`[workItems] ${r.repoId} failed:`, err)
failedCount += 1
// Why: track reachability separately so the aggregate below can
// distinguish a global GitHub failure from a partial repo failure.
if (classifyGitHubUnavailable(err instanceof Error ? err.message : String(err))) {
unavailableFailureCount += 1
}
return [] as GitHubWorkItem[]
}
})
@@ -2916,9 +2916,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
// eligible source failed for a GitHub reachability reason; intentionally
// skipped SSH repos are not GitHub sources for this calculation.
const githubUnavailable =
failedCount > 0 &&
failedCount === repos.length - skippedSourceCount &&
unavailableFailureCount === failedCount
requestFailureCount > 0 &&
requestFailureCount === repos.length - skippedSourceCount &&
unavailableFailureCount === requestFailureCount
return { items: merged, failedCount, githubUnavailable }
},