From d7d28cd7a8ee56d268e0e68631ab9ef5f88e89ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:37:04 -0700 Subject: [PATCH] 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. --- src/main/github/client.ts | 22 ++--- src/renderer/src/components/TaskPage.tsx | 37 ++++++-- .../components/right-sidebar/ChecksPanel.tsx | 14 ++++ .../checks-panel-empty-state.test.ts | 41 +++++++++ .../right-sidebar/checks-panel-empty-state.ts | 50 ++++++----- .../github-refresh-error-copy.ts | 84 +++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 21 ++++- src/renderer/src/i18n/locales/es.json | 21 ++++- src/renderer/src/i18n/locales/ja.json | 21 ++++- src/renderer/src/i18n/locales/ko.json | 21 ++++- src/renderer/src/i18n/locales/zh.json | 21 ++++- src/renderer/src/store/slices/github.test.ts | 47 ++++++++++- src/renderer/src/store/slices/github.ts | 26 +++++- src/shared/github-api-availability.test.ts | 58 +++++++++++++ src/shared/github-api-availability.ts | 52 ++++++++++++ src/shared/types.ts | 21 +++-- 16 files changed, 501 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/github-refresh-error-copy.ts create mode 100644 src/shared/github-api-availability.test.ts create mode 100644 src/shared/github-api-availability.ts diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 54572bd406f..f4932bc7d3c 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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['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': diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index af47d26736d..d2554f36ab4 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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 { ) : 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). +
+ {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." + )} +
+ ) : null} + + {!tasksError && !githubUnavailable && failedCount > 0 ? ( // Why: per-repo partial-failure signal — distinct from a hard // IPC reject (tasksError). The two are mutually exclusive.
@@ -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) ? (
diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 9572ade51d6..ac08b19fdf9 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -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 (
+ {/* 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' ? ( +
+ {getChecksPanelRefreshErrorBannerLine(prRefreshState.errorType)} +
+ ) : null} {/* Hosted review header */}
{/* Review number + state badge + refresh + open link */} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts index 7f4242cdb72..f5c726c1219 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts @@ -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( diff --git a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts index 19f033d9ffb..78dd6610ae4 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts @@ -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( diff --git a/src/renderer/src/components/right-sidebar/github-refresh-error-copy.ts b/src/renderer/src/components/right-sidebar/github-refresh-error-copy.ts new file mode 100644 index 00000000000..996728b9ece --- /dev/null +++ b/src/renderer/src/components/right-sidebar/github-refresh-error-copy.ts @@ -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." + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f275b3923b6..69a8f91d140 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." + } + } + } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9261f9971d0..53368a3a6ad 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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." + } + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e1cf3313cdc..371cca61c39 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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." + } + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d22ad9a5db8..1e4ccef033e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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." + } + } + } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b9fe5b678d6..ef2f9fc0bdb 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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." + } + } + } } } }, diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 6ddba03caec..57837420c15 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index ef5e67111a5..38ec738ec67 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -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 = (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 = (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 = (s status: 'error', reason: event.reason, updatedAt: Date.now(), - message: event.outcome.message + message: event.outcome.message, + errorType: event.outcome.errorType } continue } diff --git a/src/shared/github-api-availability.test.ts b/src/shared/github-api-availability.test.ts new file mode 100644 index 00000000000..0d5e459765e --- /dev/null +++ b/src/shared/github-api-availability.test.ts @@ -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) + }) +}) diff --git a/src/shared/github-api-availability.ts b/src/shared/github-api-availability.ts new file mode 100644 index 00000000000..7af7d1df1d8 --- /dev/null +++ b/src/shared/github-api-availability.ts @@ -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 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 3de8e943d16..985eeca150c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 }