mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* Clarify PR panel guidance: classify errors and confirm-only composer Replace the ambiguous GitHub hosted-review boolean with a four-state evidence model (found/positive_unresolved/not_found/unknown) so "No PR found" never appears without an accepted lookup result. Classify GitHub refresh failures into types (rate_limited, auth, network, permission, repo_unavailable, gh_unavailable, unknown) for stable, honest copy. Confirmed-only composer: preserve drafts across transient failures; hide Create during hard errors and positive-unresolved evidence. Hard errors clear only when an eligibility request starts after the error and returns an accepted outcome. Propagate error types and unified retry schedule through the store. Sync mobile parity with shouldOpenChecksPanelCreateComposer gating. Localize all new copy. * Clarify PR panel guidance: classify errors and confirm-only composer Add reviewLookupOutcome to hosted-review eligibility and thread it through the panel so it never claims "No PR found" without accepted evidence. A failed lookup is unavailable, not a settled no-PR. Fail closed on positive unresolved evidence, hard refresh errors, and unavailable lookups. Add structured GitHub refresh-error classification with Retry-After parsing. Implement confirmed-only composer gating based on fresh, matching-context eligibility with hard-error clearing. Mobile gates on reviewLookupOutcome to prevent false Create claims. Surface throwOnFailure variants for each provider so transport failures cross the RPC boundary instead of collapsing to null. (Design success criteria 1–4; invariant 8.) * Add exec-error helpers for subprocess error classification Extracts stderr/stdout parsing and Retry-After detection into a lightweight module that can be imported without pulling in the heavier runner machinery. Supports PR-refresh error classification and proper rate-limit handling for gh commands. * test(mobile): include reviewLookupOutcome in create eligibility fixtures Create / Push & Create now fails closed unless the lookup is not_found. Update mobile test fixtures so accepted-no-PR cases can still proceed. * Add OrThrow mock variants to forge-provider test mocks forge-provider resolves branch reviews via the OrThrow variant so lookup failures surface as unavailable instead of "no PR found".
145 lines
6.0 KiB
TypeScript
145 lines
6.0 KiB
TypeScript
import type { PRRefreshErrorType } from '../../shared/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.'
|
|
}
|
|
}
|