Files
orca/src/main/github/pr-refresh-error-classification.ts
T
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00

145 lines
6.1 KiB
TypeScript

import type { PRRefreshErrorType } from '../../shared/github/pull-request-refresh-types'
import { classifyGitHubUnavailable } from '../../shared/github/api-availability'
import { extractExecError } from '../git/exec-error'
/**
* Sanitization boundary for GitHub PR-refresh failures. Maps a raw runner/CLI
* error to a stable {@link PRRefreshErrorType}; the renderer turns that into
* classified copy so raw stderr / env values never reach the UI.
*
* Classification order (see docs/reference/pr-panel-refresh-guidance.md):
* HTTP 429 / secondary rate limit → primary rate limit → repo_unavailable (404) →
* network → permission (403) → gh_unavailable → auth → unknown. GitHub returns
* 403 OR 429 for both primary and secondary limits, so the http-403 permission
* branch must run only after the rate-limit checks. 404 is matched before the
* network branch so a repository error whose message happens to contain a
* substring like "network" is not misread as a connectivity failure. Auth and
* network detection avoid broad substrings (e.g. "auth" inside "author", or a
* repo name containing "network") in favor of structured codes and full phrases.
*/
export function classifyPRRefreshError(err: unknown): PRRefreshErrorType {
// Why: the git/gh runner keeps diagnostics on `.stderr`/`.stdout` separate
// from `.message` (which can be just "gh exited with 1."). Reading message
// alone would misclassify a real "HTTP 403" / "HTTP 404" as `unknown`, so a
// hard error would be treated as transient. Combine all three for substring
// detection while still preferring the structured spawn `code`.
const { stderr, stdout } = extractExecError(err)
const message = err instanceof Error ? err.message : String(err)
const lower = `${message}\n${stderr}\n${stdout}`.toLowerCase()
const code =
err && typeof err === 'object' && 'code' in err
? String((err as { code?: unknown }).code ?? '').toLowerCase()
: ''
// Rate limits first: a 429 is always a rate-limit signal; secondary limits also
// arrive as abuse-mechanism phrasing or a 403/429 carrying Retry-After, none of
// which contain "rate limit".
const isHttp429 = lower.includes('http 429') || lower.includes('429 too many requests')
const isHttp403 = lower.includes('http 403')
const hasRetryAfter = lower.includes('retry-after')
if (
isHttp429 ||
lower.includes('secondary rate limit') ||
lower.includes('abuse detection') ||
lower.includes('abuse-rate-limits') ||
lower.includes('you have triggered an abuse') ||
((isHttp403 || isHttp429) && hasRetryAfter) ||
lower.includes('api rate limit exceeded') ||
lower.includes('rate limit')
) {
return 'rate_limited'
}
// Repository resolution failures (404) rank before the network branch: a "could
// not resolve to a Repository" message must not be captured by a connectivity
// heuristic, and matching 404 first isolates it from any incidental substring.
if (lower.includes('http 404') || lower.includes('could not resolve to a repository')) {
return 'repo_unavailable'
}
// Why: keep GitHub 5xx/network attribution aligned with other GitHub surfaces.
const unavailable = classifyGitHubUnavailable(lower)
if (unavailable) {
return unavailable
}
// Network: structured error codes and full connectivity phrases only. Never a
// bare "network" substring — a repo/branch/message containing "network" is not
// evidence of a connectivity failure.
const networkCodes = new Set([
'etimedout',
'econnreset',
'econnrefused',
'enotfound',
'eai_again',
'enetunreach',
'enetdown'
])
if (
networkCodes.has(code) ||
lower.includes('etimedout') ||
lower.includes('econnreset') ||
lower.includes('econnrefused') ||
lower.includes('enotfound') ||
lower.includes('eai_again') ||
lower.includes('timed out') ||
lower.includes('timeout') ||
lower.includes('could not resolve host') ||
lower.includes('no such host') ||
lower.includes('network is unreachable') ||
lower.includes('network is down') ||
lower.includes('network error') ||
lower.includes('connection refused') ||
lower.includes('connection reset')
) {
return 'network'
}
if (isHttp403 || lower.includes('resource not accessible')) {
return 'permission'
}
// gh CLI launch failure: prefer the structured spawn error code over a broad
// substring so a repo path merely containing "gh" is not misclassified.
if (
code === 'enoent' ||
lower.includes('spawn gh enoent') ||
lower.includes('gh: command not found') ||
lower.includes("'gh' is not recognized")
) {
return 'gh_unavailable'
}
// Auth last: match full auth phrases and a 401, never a bare "auth" substring
// that also fires on "author"/"authored". "unauthorized"/"authorization" are
// long enough not to collide with "author".
if (
lower.includes('http 401') ||
lower.includes('unauthorized') ||
lower.includes('authorization') ||
lower.includes('authentication') ||
lower.includes('bad credentials') ||
lower.includes('gh auth') ||
/\b(login|credentials?)\b/i.test(lower)
) {
return 'auth'
}
return 'unknown'
}
/** Stable, non-destructive fallback message for a classified refresh error. */
export function safePRRefreshErrorMessage(errorType: PRRefreshErrorType): string {
switch (errorType) {
case 'rate_limited':
return 'GitHub rate limit is low. Try again after the limit resets.'
case 'auth':
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':
return 'The GitHub repository is unavailable or cannot be resolved.'
case 'gh_unavailable':
return 'GitHub CLI is unavailable.'
case 'unknown':
return 'GitHub pull request refresh failed.'
}
}