mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
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:
+11
-11
@@ -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':
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user