fix(github): attribute GitHub API outages instead of blank/"failed" states

When GitHub's API is unreachable (5xx outage, network, or rate limit), Orca
showed no PR data with no explanation, so it read as an Orca bug rather than a
GitHub-side problem.

- Add a shared classifier (classifyGitHubUnavailable) reused by the main
  process and the renderer so every surface attributes an outage identically.
  A live outage returns HTTP 5xx, which the PR-refresh classifier previously
  had no branch for (fell through to the un-attributed "refresh failed").
- Right-sidebar Checks panel: show GitHub-attributed copy in the error
  empty-state, plus an inline banner over stale cached PR data so an outage
  doesn't look like a normal (silently out-of-date) panel.
- Tasks/PR-list page: replace the vague "N of M projects failed to load" with
  a GitHub-attributed banner when the failure is a reachability problem.

Copy names GitHub as the source and reassures it isn't an Orca problem, with no
status-page link. Stays GitHub-scoped so GitLab/other providers aren't
mislabeled.
This commit is contained in:
Brennan Benson
2026-07-16 17:37:04 -07:00
parent 8461df2139
commit d7d28cd7a8
16 changed files with 501 additions and 56 deletions
+11 -11
View File
@@ -29,6 +29,7 @@ import {
} from '../../shared/hosted-review-refs'
import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github-pr-merge-methods'
import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-query-bounds'
import { classifyGitHubUnavailable } from '../../shared/github-api-availability'
import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query'
import {
GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE,
@@ -184,18 +185,15 @@ function classifyPRRefreshError(
err: unknown
): Extract<PRRefreshOutcome, { kind: 'upstream-error' }>['errorType'] {
const message = err instanceof Error ? err.message : String(err)
// Why: GitHub-unreachable buckets (5xx outage / network / rate limit) share
// one detector with the Tasks work-item path so both surfaces attribute an
// outage to GitHub identically. Rate-limit responses also carry "HTTP 403",
// so this must run before the permission check below.
const unavailable = classifyGitHubUnavailable(message)
if (unavailable) {
return unavailable
}
const lower = message.toLowerCase()
if (lower.includes('rate limit')) {
return 'rate_limited'
}
if (
lower.includes('timeout') ||
lower.includes('no such host') ||
lower.includes('network') ||
lower.includes('could not resolve host')
) {
return 'network'
}
if (lower.includes('http 403') || lower.includes('resource not accessible')) {
return 'permission'
}
@@ -215,6 +213,8 @@ function safePRRefreshErrorMessage(
return 'GitHub authentication is unavailable. Check your gh login.'
case 'network':
return 'GitHub is unreachable right now. Check your network and try again.'
case 'server_error':
return "GitHub's API is temporarily unavailable (server error). This is a GitHub-side issue."
case 'permission':
return 'GitHub did not allow access to this pull request.'
case 'repo_unavailable':
+30 -7
View File
@@ -3788,6 +3788,10 @@ 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.
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
// user clicking the refresh button (force=true) vs. re-running for any
@@ -6414,6 +6418,7 @@ export default function TaskPage(): React.JSX.Element {
setCountedTotalPages(null)
setTasksError(null)
setFailedCount(0) // reset so a prior failure banner doesn't linger
setGithubUnavailable(false)
setTasksLoading(anyUncached)
// Preserve the existing nonce-gated force behavior.
@@ -6456,7 +6461,7 @@ export default function TaskPage(): React.JSX.Element {
void fetchWorkItemsAcrossRepos(repoArgs, githubPerRepoPageLimit, githubPageSize, q, {
...deriveTaskPageGitHubWorkItemsFetchOptions(forcedFetch, shouldProbeOnLanding)
})
.then(({ items, failedCount: failed }) => {
.then(({ items, failedCount: failed, githubUnavailable: unavailable }) => {
// Why: clear only the sources this effect was responsible for
// retrying (the snapshot captured at dispatch time). Overlapping
// retries — a second click while a prior fetch is still in flight
@@ -6488,6 +6493,7 @@ export default function TaskPage(): React.JSX.Element {
setCurrentPage(0)
}
setFailedCount(failed)
setGithubUnavailable(unavailable)
setTasksLoading(false)
setTasksRefreshing(false)
setTasksFiltering(false)
@@ -6516,6 +6522,7 @@ export default function TaskPage(): React.JSX.Element {
}
setTasksError(err instanceof Error ? err.message : 'Failed to load GitHub work.')
setFailedCount(0) // the per-repo banner would be misleading next to tasksError
setGithubUnavailable(false)
setTasksLoading(false)
setTasksRefreshing(false)
setTasksFiltering(false)
@@ -9297,7 +9304,22 @@ export default function TaskPage(): React.JSX.Element {
</div>
) : null}
{!tasksError && failedCount > 0 ? (
{!tasksError && githubUnavailable ? (
// Why: attribute a GitHub outage explicitly so an empty list
// doesn't read as an Orca bug. Takes priority over the generic
// count banner below (the two are mutually exclusive).
<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"
>
{translate(
'auto.components.TaskPage.a735832e9c',
"Can't reach GitHub — its API may be temporarily unavailable. This isn't an Orca problem; your pull requests and issues will reappear once GitHub is back."
)}
</div>
) : null}
{!tasksError && !githubUnavailable && failedCount > 0 ? (
// Why: per-repo partial-failure signal — distinct from a hard
// IPC reject (tasksError). The two are mutually exclusive.
<div className="border-b border-border/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
@@ -9406,14 +9428,15 @@ export default function TaskPage(): React.JSX.Element {
) : null}
{/* Why: suppress the generic empty state when any error banner is
visible (IPC reject via tasksError, cross-repo partial failure
via failedCount, or per-repo issue-side error). Showing
"No matching GitHub work" next to "Couldn't load issues from X/Y"
is contradictory and misleads the user into thinking they
typed the wrong query. */}
visible (IPC reject via tasksError, GitHub-unavailable outage,
cross-repo partial failure via failedCount, or per-repo
issue-side error). Showing "No matching GitHub work" next to
"Couldn't load issues from X/Y" is contradictory and misleads
the user into thinking they typed the wrong query. */}
{!showGitHubTaskSkeletons &&
filteredWorkItems.length === 0 &&
!tasksError &&
!githubUnavailable &&
failedCount === 0 &&
perRepoSourceState.every((s) => !s.error) ? (
<div className="px-4 py-10 text-center">
@@ -102,6 +102,7 @@ import {
getChecksPanelEmptyStateCopy,
shouldShowChecksPanelPublishBranchAction
} from './checks-panel-empty-state'
import { getChecksPanelRefreshErrorBannerLine } from './github-refresh-error-copy'
import { hasAmbiguousGitHubHostedReviewForChecksPanel } from './checks-panel-ambiguous-github-review'
import { recordChecksPanelPRRefreshBreadcrumb } from './checks-panel-pr-refresh-breadcrumb'
import {
@@ -3583,6 +3584,7 @@ export default function ChecksPanel(): React.JSX.Element {
const emptyStateCopy = getChecksPanelEmptyStateCopy({
operationLabel,
prRefreshStatus: emptyReviewIsGitLab ? undefined : prRefreshState?.status,
prRefreshErrorType: emptyReviewIsGitLab ? undefined : prRefreshState?.errorType,
hostedReviewBlockedReason: hostedReviewCreation?.blockedReason,
hasUpstream: publishActionRemoteStatus?.hasUpstream,
hasCurrentBranch: Boolean(branch),
@@ -3699,6 +3701,18 @@ export default function ChecksPanel(): React.JSX.Element {
!settings.activeRuntimeEnvironmentId
return (
<div ref={setChecksPanelContentRef} className="flex-1 overflow-auto scrollbar-sleek">
{/* Why: a background refresh failed while cached PR data is still shown.
Surface it over the stale data so a GitHub outage doesn't look like a
normal (but silently out-of-date) panel. GitHub-only — GitLab reviews
don't flow through prRefreshState. */}
{activeReview?.provider === 'github' && prRefreshState?.status === 'error' ? (
<div
role="alert"
className="border-b border-border/50 bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
{getChecksPanelRefreshErrorBannerLine(prRefreshState.errorType)}
</div>
) : null}
{/* Hosted review header */}
<div className="px-3 py-3 border-b border-border space-y-2.5">
{/* Review number + state badge + refresh + open link */}
@@ -220,6 +220,47 @@ describe('getChecksPanelEmptyStateCopy', () => {
).toBe('Could not refresh pull request')
})
it('attributes a 5xx outage to GitHub instead of the generic refresh error', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'error',
prRefreshErrorType: 'server_error',
hostedReviewBlockedReason: null,
hasUpstream: true
})
).toEqual({
title: 'GitHub is unavailable',
description:
"GitHub's API is temporarily unavailable — a GitHub-side outage, not Orca. This panel reloads automatically once it recovers."
})
})
it('attributes a network failure to GitHub in the ambiguous hosted-review branch', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'error',
prRefreshErrorType: 'network',
hostedReviewBlockedReason: null,
hasUpstream: true,
hasAmbiguousGitHubHostedReview: true
}).title
).toBe("Can't reach GitHub")
})
it('keeps the generic refresh error for non-outage error types', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'error',
prRefreshErrorType: 'auth',
hostedReviewBlockedReason: null,
hasUpstream: true
}).title
).toBe('Could not refresh pull request')
})
it('renders a single stable title across a full ambiguous refresh cycle', () => {
const cycle = ['queued', 'in-flight', undefined, 'skipped', 'paused'] as const
const titles = new Set(
@@ -1,11 +1,17 @@
import type { HostedReviewCreationBlockedReason } from '../../../../shared/hosted-review'
import type { PRRefreshUpstreamErrorType } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import { getGitHubUnavailableEmptyStateCopy } from './github-refresh-error-copy'
type PRRefreshStatus = 'queued' | 'in-flight' | 'paused' | 'error' | 'skipped' | undefined
type ChecksPanelEmptyStateInput = {
operationLabel: string | null
prRefreshStatus: PRRefreshStatus
// Why: lets the error empty-state attribute a GitHub outage (5xx/network/rate
// limit) instead of showing generic "could not refresh" copy that reads like
// an Orca bug.
prRefreshErrorType?: PRRefreshUpstreamErrorType
hostedReviewBlockedReason: HostedReviewCreationBlockedReason | undefined
hasUpstream: boolean | undefined
hasCurrentBranch?: boolean
@@ -52,16 +58,18 @@ export function getChecksPanelEmptyStateCopy(
// hard refresh error still surfaces a distinct — but equally stable — message.
if (input.hasAmbiguousGitHubHostedReview === true) {
if (input.prRefreshStatus === 'error') {
return {
title: translate(
'auto.components.right.sidebar.checks.panel.empty.state.5f478ab3d3',
'Could not refresh pull request'
),
description: translate(
'auto.components.right.sidebar.checks.panel.empty.state.2bdd7aaf2d',
'GitHub status could not be refreshed. Existing cached data was preserved.'
)
}
return (
getGitHubUnavailableEmptyStateCopy(input.prRefreshErrorType) ?? {
title: translate(
'auto.components.right.sidebar.checks.panel.empty.state.5f478ab3d3',
'Could not refresh pull request'
),
description: translate(
'auto.components.right.sidebar.checks.panel.empty.state.2bdd7aaf2d',
'GitHub status could not be refreshed. Existing cached data was preserved.'
)
}
)
}
return {
title: translate(
@@ -113,16 +121,18 @@ export function getChecksPanelEmptyStateCopy(
switch (input.prRefreshStatus) {
case 'error':
return {
title: translate(
'auto.components.right.sidebar.checks.panel.empty.state.5f478ab3d3',
'Could not refresh pull request'
),
description: translate(
'auto.components.right.sidebar.checks.panel.empty.state.2bdd7aaf2d',
'GitHub status could not be refreshed. Existing cached data was preserved.'
)
}
return (
getGitHubUnavailableEmptyStateCopy(input.prRefreshErrorType) ?? {
title: translate(
'auto.components.right.sidebar.checks.panel.empty.state.5f478ab3d3',
'Could not refresh pull request'
),
description: translate(
'auto.components.right.sidebar.checks.panel.empty.state.2bdd7aaf2d',
'GitHub status could not be refreshed. Existing cached data was preserved.'
)
}
)
case 'queued':
return {
title: translate(
@@ -0,0 +1,84 @@
import type { PRRefreshUpstreamErrorType } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export type ChecksPanelErrorCopy = { title: string; description: string }
/**
* Attributed empty-state copy for a PR-refresh failure caused by GitHub itself
* being unavailable (5xx outage / network / rate limit). Returns null for
* non-outage errors (auth, permission, unknown) so the caller keeps its generic
* "could not refresh" copy — those are user-actionable, not "GitHub is down".
*/
export function getGitHubUnavailableEmptyStateCopy(
errorType: PRRefreshUpstreamErrorType | undefined
): ChecksPanelErrorCopy | null {
if (errorType === 'server_error') {
return {
title: translate(
'auto.components.right.sidebar.github.refresh.error.copy.580025e7b7',
'GitHub is unavailable'
),
description: translate(
'auto.components.right.sidebar.github.refresh.error.copy.01c85b5770',
"GitHub's API is temporarily unavailable — a GitHub-side outage, not Orca. This panel reloads automatically once it recovers."
)
}
}
if (errorType === 'network') {
return {
title: translate(
'auto.components.right.sidebar.github.refresh.error.copy.d1a9f2b165',
"Can't reach GitHub"
),
description: translate(
'auto.components.right.sidebar.github.refresh.error.copy.7d01d42a3a',
"GitHub is unreachable right now — check your connection. This isn't an Orca problem."
)
}
}
if (errorType === 'rate_limited') {
return {
title: translate(
'auto.components.right.sidebar.github.refresh.error.copy.e9d681894a',
'GitHub rate limit reached'
),
description: translate(
'auto.components.right.sidebar.github.refresh.error.copy.8c77434d6f',
'GitHub is rate-limiting requests. This panel refreshes once the limit resets.'
)
}
}
// Non-outage errors (auth, permission, unknown) keep the caller's generic copy.
return null
}
/**
* One-line banner shown over stale cached PR data when the last refresh failed.
* Always returns a line: GitHub-attributed for outage kinds, generic otherwise.
*/
export function getChecksPanelRefreshErrorBannerLine(
errorType: PRRefreshUpstreamErrorType | undefined
): string {
if (errorType === 'server_error') {
return translate(
'auto.components.right.sidebar.github.refresh.error.copy.79aa06bb2c',
"Couldn't refresh — GitHub's API is temporarily unavailable. Showing the last known status."
)
}
if (errorType === 'network') {
return translate(
'auto.components.right.sidebar.github.refresh.error.copy.6ec12cee0c',
"Couldn't refresh — GitHub is unreachable right now. Showing the last known status."
)
}
if (errorType === 'rate_limited') {
return translate(
'auto.components.right.sidebar.github.refresh.error.copy.de088015e8',
"Couldn't refresh — GitHub is rate-limiting requests. Showing the last known status."
)
}
return translate(
'auto.components.right.sidebar.github.refresh.error.copy.d9dd7c6687',
"Couldn't refresh from GitHub. Showing the last known status."
)
}
+20 -1
View File
@@ -1758,7 +1758,8 @@
"jiraSortAscending": "ascending",
"jiraSortDescending": "descending",
"jiraSortBy": "Sort by",
"jiraToggleSortDirection": "Sort {{value0}}"
"jiraToggleSortDirection": "Sort {{value0}}",
"a735832e9c": "Can't reach GitHub — its API may be temporarily unavailable. This isn't an Orca problem; your pull requests and issues will reappear once GitHub is back."
},
"Terminal": {
"73768427cf": "Close",
@@ -10267,6 +10268,24 @@
"workspaceGone": "Couldn't open log — workspace is no longer available.",
"notAuthorized": "Couldn't open log — path not authorized.",
"alreadyEditable": "Log is already open for editing."
},
"github": {
"refresh": {
"error": {
"copy": {
"580025e7b7": "GitHub is unavailable",
"01c85b5770": "GitHub's API is temporarily unavailable — a GitHub-side outage, not Orca. This panel reloads automatically once it recovers.",
"d1a9f2b165": "Can't reach GitHub",
"7d01d42a3a": "GitHub is unreachable right now — check your connection. This isn't an Orca problem.",
"e9d681894a": "GitHub rate limit reached",
"8c77434d6f": "GitHub is rate-limiting requests. This panel refreshes once the limit resets.",
"79aa06bb2c": "Couldn't refresh — GitHub's API is temporarily unavailable. Showing the last known status.",
"6ec12cee0c": "Couldn't refresh — GitHub is unreachable right now. Showing the last known status.",
"de088015e8": "Couldn't refresh — GitHub is rate-limiting requests. Showing the last known status.",
"d9dd7c6687": "Couldn't refresh from GitHub. Showing the last known status."
}
}
}
}
}
},
+20 -1
View File
@@ -1758,7 +1758,8 @@
"jiraSortAscending": "ascendente",
"jiraSortDescending": "descendente",
"jiraSortBy": "Ordenar por",
"jiraToggleSortDirection": "Ordenar de forma {{value0}}"
"jiraToggleSortDirection": "Ordenar de forma {{value0}}",
"a735832e9c": "Can't reach GitHub — its API may be temporarily unavailable. This isn't an Orca problem; your pull requests and issues will reappear once GitHub is back."
},
"Terminal": {
"73768427cf": "Cerrar",
@@ -10267,6 +10268,24 @@
"workspaceGone": "Couldn't open log — workspace is no longer available.",
"notAuthorized": "Couldn't open log — path not authorized.",
"alreadyEditable": "Log is already open for editing."
},
"github": {
"refresh": {
"error": {
"copy": {
"580025e7b7": "GitHub is unavailable",
"01c85b5770": "GitHub's API is temporarily unavailable — a GitHub-side outage, not Orca. This panel reloads automatically once it recovers.",
"d1a9f2b165": "Can't reach GitHub",
"7d01d42a3a": "GitHub is unreachable right now — check your connection. This isn't an Orca problem.",
"e9d681894a": "GitHub rate limit reached",
"8c77434d6f": "GitHub is rate-limiting requests. This panel refreshes once the limit resets.",
"79aa06bb2c": "Couldn't refresh — GitHub's API is temporarily unavailable. Showing the last known status.",
"6ec12cee0c": "Couldn't refresh — GitHub is unreachable right now. Showing the last known status.",
"de088015e8": "Couldn't refresh — GitHub is rate-limiting requests. Showing the last known status.",
"d9dd7c6687": "Couldn't refresh from GitHub. Showing the last known status."
}
}
}
}
}
},
+20 -1
View File
@@ -1758,7 +1758,8 @@
"jiraSortAscending": "昇順",
"jiraSortDescending": "降順",
"jiraSortBy": "並べ替え",
"jiraToggleSortDirection": "{{value0}}に並べ替え"
"jiraToggleSortDirection": "{{value0}}に並べ替え",
"a735832e9c": "Can't reach GitHub — its API may be temporarily unavailable. This isn't an Orca problem; your pull requests and issues will reappear once GitHub is back."
},
"Terminal": {
"73768427cf": "閉じる",
@@ -10267,6 +10268,24 @@
"workspaceGone": "Couldn't open log — workspace is no longer available.",
"notAuthorized": "Couldn't open log — path not authorized.",
"alreadyEditable": "Log is already open for editing."
},
"github": {
"refresh": {
"error": {
"copy": {
"580025e7b7": "GitHub is unavailable",
"01c85b5770": "GitHub's API is temporarily unavailable — a GitHub-side outage, not Orca. This panel reloads automatically once it recovers.",
"d1a9f2b165": "Can't reach GitHub",
"7d01d42a3a": "GitHub is unreachable right now — check your connection. This isn't an Orca problem.",
"e9d681894a": "GitHub rate limit reached",
"8c77434d6f": "GitHub is rate-limiting requests. This panel refreshes once the limit resets.",
"79aa06bb2c": "Couldn't refresh — GitHub's API is temporarily unavailable. Showing the last known status.",
"6ec12cee0c": "Couldn't refresh — GitHub is unreachable right now. Showing the last known status.",
"de088015e8": "Couldn't refresh — GitHub is rate-limiting requests. Showing the last known status.",
"d9dd7c6687": "Couldn't refresh from GitHub. Showing the last known status."
}
}
}
}
}
},
+20 -1
View File
@@ -1758,7 +1758,8 @@
"jiraSortAscending": "오름차순",
"jiraSortDescending": "내림차순",
"jiraSortBy": "정렬 기준",
"jiraToggleSortDirection": "{{value0}}으로 정렬"
"jiraToggleSortDirection": "{{value0}}으로 정렬",
"a735832e9c": "Can't reach GitHub — its API may be temporarily unavailable. This isn't an Orca problem; your pull requests and issues will reappear once GitHub is back."
},
"Terminal": {
"73768427cf": "닫기",
@@ -10267,6 +10268,24 @@
"workspaceGone": "Couldn't open log — workspace is no longer available.",
"notAuthorized": "Couldn't open log — path not authorized.",
"alreadyEditable": "Log is already open for editing."
},
"github": {
"refresh": {
"error": {
"copy": {
"580025e7b7": "GitHub is unavailable",
"01c85b5770": "GitHub's API is temporarily unavailable — a GitHub-side outage, not Orca. This panel reloads automatically once it recovers.",
"d1a9f2b165": "Can't reach GitHub",
"7d01d42a3a": "GitHub is unreachable right now — check your connection. This isn't an Orca problem.",
"e9d681894a": "GitHub rate limit reached",
"8c77434d6f": "GitHub is rate-limiting requests. This panel refreshes once the limit resets.",
"79aa06bb2c": "Couldn't refresh — GitHub's API is temporarily unavailable. Showing the last known status.",
"6ec12cee0c": "Couldn't refresh — GitHub is unreachable right now. Showing the last known status.",
"de088015e8": "Couldn't refresh — GitHub is rate-limiting requests. Showing the last known status.",
"d9dd7c6687": "Couldn't refresh from GitHub. Showing the last known status."
}
}
}
}
}
},
+20 -1
View File
@@ -1758,7 +1758,8 @@
"jiraSortAscending": "升序",
"jiraSortDescending": "降序",
"jiraSortBy": "排序方式",
"jiraToggleSortDirection": "按{{value0}}排序"
"jiraToggleSortDirection": "按{{value0}}排序",
"a735832e9c": "Can't reach GitHub — its API may be temporarily unavailable. This isn't an Orca problem; your pull requests and issues will reappear once GitHub is back."
},
"Terminal": {
"73768427cf": "关闭",
@@ -10267,6 +10268,24 @@
"workspaceGone": "无法打开日志 — 工作区已不可用。",
"notAuthorized": "无法打开日志 — 路径未授权。",
"alreadyEditable": "日志已处于编辑状态。"
},
"github": {
"refresh": {
"error": {
"copy": {
"580025e7b7": "GitHub is unavailable",
"01c85b5770": "GitHub's API is temporarily unavailable — a GitHub-side outage, not Orca. This panel reloads automatically once it recovers.",
"d1a9f2b165": "Can't reach GitHub",
"7d01d42a3a": "GitHub is unreachable right now — check your connection. This isn't an Orca problem.",
"e9d681894a": "GitHub rate limit reached",
"8c77434d6f": "GitHub is rate-limiting requests. This panel refreshes once the limit resets.",
"79aa06bb2c": "Couldn't refresh — GitHub's API is temporarily unavailable. Showing the last known status.",
"6ec12cee0c": "Couldn't refresh — GitHub is unreachable right now. Showing the last known status.",
"de088015e8": "Couldn't refresh — GitHub is rate-limiting requests. Showing the last known status.",
"d9dd7c6687": "Couldn't refresh from GitHub. Showing the last known status."
}
}
}
}
}
},
+46 -1
View File
@@ -6299,6 +6299,51 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
}
})
it('flags githubUnavailable when a GitHub repo fails with a 5xx outage and no cache', async () => {
const store = createTestStore()
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockApi.gh.listWorkItems.mockRejectedValue(new Error('HTTP 503: Service Unavailable'))
try {
const result = await store
.getState()
.fetchWorkItemsAcrossRepos(
[{ repoId: 'github-repo', path: '/server/github-repo' }],
24,
100,
''
)
expect(result.items).toEqual([])
expect(result.failedCount).toBe(1)
expect(result.githubUnavailable).toBe(true)
} finally {
consoleWarn.mockRestore()
}
})
it('does not flag githubUnavailable for a 404 (not an outage)', async () => {
const store = createTestStore()
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockApi.gh.listWorkItems.mockRejectedValue(new Error('HTTP 404: Not Found'))
try {
const result = await store
.getState()
.fetchWorkItemsAcrossRepos(
[{ repoId: 'github-repo', path: '/server/github-repo' }],
24,
100,
''
)
expect(result.failedCount).toBe(1)
expect(result.githubUnavailable).toBe(false)
} finally {
consoleWarn.mockRestore()
}
})
it('quietly skips SSH repos without a resolved GitHub remote in next-page fetches', async () => {
const store = createTestStore()
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -6482,7 +6527,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
24,
oversizedQuery
)
).resolves.toEqual({ items: [], failedCount: 0 })
).resolves.toEqual({ items: [], failedCount: 0, githubUnavailable: false })
await expect(
store
.getState()
+22 -4
View File
@@ -12,6 +12,7 @@ import type {
GitHubPRRefreshCandidate,
GitHubPRRefreshEvent,
GitHubPRRefreshReason,
PRRefreshUpstreamErrorType,
GitHubCommentResult,
IssueInfo,
PRCheckDetail,
@@ -46,6 +47,7 @@ import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-revi
import { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity'
import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from './github-cache-key'
import { 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'
import {
@@ -659,6 +661,10 @@ export type PRRefreshState = {
updatedAt: number
pausedUntil?: number
message?: string
// Why: lets error surfaces render translated, GitHub-attributed copy keyed on
// the failure kind (e.g. a 5xx outage vs. a rate limit) rather than showing
// the main process's English `message` verbatim.
errorType?: PRRefreshUpstreamErrorType
}
export type PRRefreshStateClearToken = {
@@ -2030,6 +2036,11 @@ export type GitHubSlice = {
* the caller surfaces as a "N of M projects failed to load" banner. A repo
* 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 at least one GitHub source failed (with no
* cache) and the failure looks like GitHub being unreachable/unavailable
* (5xx outage, network, or rate limit). The caller uses it to attribute the
* empty list to a GitHub outage instead of the generic failure banner.
*/
fetchWorkItemsAcrossRepos: (
repos: {
@@ -2042,7 +2053,7 @@ export type GitHubSlice = {
displayLimit: number,
query: string,
options?: FetchOptions
) => Promise<{ items: GitHubWorkItem[]; failedCount: number }>
) => Promise<{ items: GitHubWorkItem[]; failedCount: number; githubUnavailable: boolean }>
/** Fetch one numbered provider page. Pagination pages remain renderer-local. */
fetchWorkItemsNextPage: (
repos: {
@@ -2850,10 +2861,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
fetchWorkItemsAcrossRepos: async (repos, perRepoLimit, displayLimit, query, options) => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return { items: [], failedCount: 0 }
return { items: [], failedCount: 0, githubUnavailable: false }
}
const state = get()
let failedCount = 0
let githubUnavailable = false
const perProjectResults = await Promise.all(
repos.map(async (r) => {
try {
@@ -2887,12 +2899,17 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
console.warn(`[workItems] ${r.repoId} failed:`, err)
failedCount += 1
// Why: attribute an empty list to a GitHub outage (vs. a generic
// failure) only when the fetch failed because GitHub is unreachable.
if (classifyGitHubUnavailable(err instanceof Error ? err.message : String(err))) {
githubUnavailable = true
}
return [] as GitHubWorkItem[]
}
})
)
const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit)
return { items: merged, failedCount }
return { items: merged, failedCount, githubUnavailable }
},
fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page) => {
@@ -4112,7 +4129,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
status: 'error',
reason: event.reason,
updatedAt: Date.now(),
message: event.outcome.message
message: event.outcome.message,
errorType: event.outcome.errorType
}
continue
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { classifyGitHubUnavailable, isGitHubUnavailableError } from './github-api-availability'
describe('classifyGitHubUnavailable', () => {
it('classifies HTTP 5xx outages as server_error', () => {
expect(classifyGitHubUnavailable('HTTP 503: Service Unavailable')).toBe('server_error')
expect(classifyGitHubUnavailable('gh: Command failed: HTTP 502 Bad Gateway')).toBe(
'server_error'
)
expect(classifyGitHubUnavailable('GitHub API error: 500 Internal Server Error')).toBe(
'server_error'
)
expect(classifyGitHubUnavailable('The service is temporarily unavailable')).toBe('server_error')
})
it('classifies transport failures as network', () => {
for (const message of [
'request to https://api.github.com failed, reason: getaddrinfo ENOTFOUND api.github.com',
'dial tcp: lookup api.github.com: no such host',
'connect ETIMEDOUT 140.82.112.5:443',
'fetch failed',
'socket hang up',
'could not resolve host: api.github.com',
'connection refused'
]) {
expect(classifyGitHubUnavailable(message)).toBe('network')
}
})
it('classifies rate limiting as rate_limited (even when it carries HTTP 403)', () => {
expect(classifyGitHubUnavailable('HTTP 403: API rate limit exceeded')).toBe('rate_limited')
expect(classifyGitHubUnavailable('You have exceeded a secondary rate limit')).toBe(
'rate_limited'
)
expect(classifyGitHubUnavailable('HTTP 429 Too Many Requests')).toBe('rate_limited')
})
it('returns null for non-reachability failures', () => {
expect(classifyGitHubUnavailable('HTTP 403: Resource not accessible by integration')).toBeNull()
expect(classifyGitHubUnavailable('HTTP 404: Not Found')).toBeNull()
expect(classifyGitHubUnavailable('could not resolve to a Repository with the name')).toBeNull()
expect(classifyGitHubUnavailable('gh auth login required')).toBeNull()
expect(classifyGitHubUnavailable('')).toBeNull()
})
it('does not misread unrelated 3-digit numbers as a server outage', () => {
expect(classifyGitHubUnavailable('found 512 pull requests')).toBeNull()
})
})
describe('isGitHubUnavailableError', () => {
it('detects reachability failures from Error objects and strings', () => {
expect(isGitHubUnavailableError(new Error('HTTP 503: Service Unavailable'))).toBe(true)
expect(isGitHubUnavailableError('fetch failed')).toBe(true)
expect(isGitHubUnavailableError(new Error('HTTP 404: Not Found'))).toBe(false)
expect(isGitHubUnavailableError(null)).toBe(false)
})
})
+52
View File
@@ -0,0 +1,52 @@
// Why: a GitHub outage, a dropped network, or a rate-limit all surface as
// unstructured gh/Octokit error text. Detecting them from one shared place lets
// both the main process (PR-refresh classification) and the renderer (Tasks
// work-item fan-out) attribute the failure to GitHub — not to Orca — using the
// exact same rules, so the two surfaces never disagree about whether GitHub is
// reachable. Returns null for anything that is NOT a reachability problem
// (auth, permission, 404): those are user-actionable, not "GitHub is down".
export type GitHubUnavailableKind = 'server_error' | 'network' | 'rate_limited'
// Rate-limit first: a primary rate-limit response also carries "HTTP 403", so
// it must win over any 4xx/permission interpretation downstream.
const RATE_LIMITED_PATTERN =
/rate limit|secondary rate limit|abuse detection|\bhttp[\s/]*429\b|\b429 too many requests\b/i
// Server-side outage. Anchor on "HTTP 5xx" or named 5xx statuses rather than a
// bare 3-digit match so unrelated numbers in stderr can't be misread as an
// outage.
const SERVER_ERROR_PATTERN =
/\bhttp[\s/]*5\d\d\b|\b5\d\d\s+(?:internal server error|bad gateway|service unavailable|gateway time-?out)\b|\binternal server error\b|\bbad gateway\b|\bservice unavailable\b|\bgateway time-?out\b|\bserver error\b|\btemporarily unavailable\b/i
// Transport-level failures — DNS, refused/reset connections, timeouts. Covers
// both Node (ENOTFOUND/ECONNRESET) and the gh Go client ("dial tcp", "i/o
// timeout", "no such host") shapes.
const NETWORK_PATTERN =
/\btimeout\b|\btimed out\b|\bno such host\b|could not resolve host|could not resolve to a host|\bnetwork\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
/**
* Classify a gh/Octokit error message as a GitHub-reachability problem, or
* null when it is not one (auth, permission, 404, validation, etc.).
*/
export function classifyGitHubUnavailable(message: string): GitHubUnavailableKind | null {
if (!message) {
return null
}
if (RATE_LIMITED_PATTERN.test(message)) {
return 'rate_limited'
}
if (SERVER_ERROR_PATTERN.test(message)) {
return 'server_error'
}
if (NETWORK_PATTERN.test(message)) {
return 'network'
}
return null
}
/** True when the message indicates GitHub itself is unreachable/unavailable. */
export function isGitHubUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '')
return classifyGitHubUnavailable(message) !== null
}
+13 -8
View File
@@ -1179,19 +1179,24 @@ export type PRInfo = {
conflictSummary?: PRConflictSummary
}
// server_error covers GitHub-side HTTP 5xx outages (githubstatus.com incidents),
// distinct from a transport-level `network` failure or a `rate_limited` budget.
export type PRRefreshUpstreamErrorType =
| 'rate_limited'
| 'auth'
| 'network'
| 'permission'
| 'repo_unavailable'
| 'gh_unavailable'
| 'server_error'
| 'unknown'
export type PRRefreshOutcome =
| { kind: 'found'; pr: PRInfo; fetchedAt: number }
| { kind: 'no-pr'; fetchedAt: number }
| {
kind: 'upstream-error'
errorType:
| 'rate_limited'
| 'auth'
| 'network'
| 'permission'
| 'repo_unavailable'
| 'gh_unavailable'
| 'unknown'
errorType: PRRefreshUpstreamErrorType
message: string
fetchedAt: number
}