mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
Generalize the auth-gated enterprise resolver to any remote and build a cached hosted-identity family (origin/issue/candidates/source) on top of it, then migrate every github.com-only consumer: Tasks listing/counting, branch-to-PR discovery, push targets, fork upstream, issue operations, Projects, web links, avatars, and PR-link facts. Scope the rate-limit breaker probe per runtime:host and classify WSL UNC cwds correctly. Co-authored-by: Orca <help@stably.ai>
173 lines
6.2 KiB
TypeScript
173 lines
6.2 KiB
TypeScript
// Why: turns raw gh stderr/stdout into the typed GitHubProjectViewError the
|
|
// renderer can act on (auth vs scope vs rate limit vs drift), shared by the
|
|
// project-view read and mutation paths.
|
|
import type { GitHubProjectViewError } from '../../../shared/github-project-types'
|
|
|
|
export type GhGraphqlErrorShape = {
|
|
type?: string
|
|
message?: string
|
|
path?: (string | number)[]
|
|
extensions?: { code?: string }
|
|
}
|
|
|
|
export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] {
|
|
// `gh api graphql` prints the response JSON to stdout even on GraphQL
|
|
// errors, and the stderr carries a summary. Try stdout first; if parsing
|
|
// fails, fall back to stderr.
|
|
const sources = [stdout, stderr]
|
|
for (const src of sources) {
|
|
if (!src) {
|
|
continue
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] }
|
|
if (parsed.errors && parsed.errors.length > 0) {
|
|
return parsed.errors
|
|
}
|
|
} catch {
|
|
// not JSON — continue
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean {
|
|
const lower = stderr.toLowerCase()
|
|
// Preview-header shape: gh returns a 4xx with "preview" in the message.
|
|
if (lower.includes('preview') && lower.includes('parent')) {
|
|
return true
|
|
}
|
|
return errors.some((e) => {
|
|
const type = (e.type ?? '').toUpperCase()
|
|
if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') {
|
|
const tail = e.path?.at(-1)
|
|
if (tail === 'parent') {
|
|
return true
|
|
}
|
|
// FIELD_ERRORS often omits `path`; match on message for the parent field.
|
|
if ((e.message ?? '').toLowerCase().includes('parent')) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
export function classifyProjectError(stderr: string, stdout: string): GitHubProjectViewError {
|
|
const errors = extractGraphqlErrors(stderr, stdout)
|
|
const s = stderr.toLowerCase()
|
|
|
|
// Auth
|
|
if (
|
|
s.includes('authentication required') ||
|
|
s.includes('not logged in') ||
|
|
s.includes('gh auth login')
|
|
) {
|
|
return {
|
|
type: 'auth_required',
|
|
message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.'
|
|
}
|
|
}
|
|
// Scope
|
|
if (
|
|
s.includes('missing required scope') ||
|
|
s.includes('your token has not been granted') ||
|
|
(s.includes('resource not accessible') && (s.includes('project') || s.includes('scope')))
|
|
) {
|
|
return {
|
|
type: 'scope_missing',
|
|
message:
|
|
'GitHub project access needs additional scopes. Run `gh auth refresh -s project -s read:org -s repo`.'
|
|
}
|
|
}
|
|
// Rate limit
|
|
if (s.includes('rate limit') || s.includes('api rate limit exceeded')) {
|
|
return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' }
|
|
}
|
|
// Network — checked BEFORE not_found because DNS failures surface as
|
|
// "could not resolve host", which would otherwise be partially matched by
|
|
// the not_found branch's "could not resolve" check. Substring matching here
|
|
// is a one-way trapdoor: a real GraphQL "Could not resolve to a User…"
|
|
// error always contains "to a", so we tighten the not_found check below to
|
|
// require that token.
|
|
if (
|
|
s.includes('timeout') ||
|
|
s.includes('no such host') ||
|
|
s.includes('network') ||
|
|
s.includes('could not resolve host') ||
|
|
s.includes('dial tcp')
|
|
) {
|
|
return { type: 'network_error', message: 'Network error — check your connection.' }
|
|
}
|
|
// Not found
|
|
if (
|
|
s.includes('http 404') ||
|
|
errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') ||
|
|
// Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to
|
|
// an Organization") and "to a" otherwise. The previous singular-only
|
|
// check missed the "an" variants when gh emits only the stderr summary
|
|
// without a structured GraphQL error array. See bug-scan finding 3.
|
|
/could not resolve to an? /.test(s)
|
|
) {
|
|
const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND')
|
|
return {
|
|
type: 'not_found',
|
|
message: 'Project or view not found.',
|
|
details: firstNotFound
|
|
? { path: firstNotFound.path, code: firstNotFound.extensions?.code }
|
|
: undefined
|
|
}
|
|
}
|
|
// Validation
|
|
if (s.includes('http 422') || s.includes('validation failed')) {
|
|
return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` }
|
|
}
|
|
// GraphQL error with structured info
|
|
if (errors.length > 0) {
|
|
const first = errors[0]
|
|
return {
|
|
type: 'unknown',
|
|
message: first.message ?? 'Unknown GraphQL error.',
|
|
details: { path: first.path, code: first.extensions?.code }
|
|
}
|
|
}
|
|
// Why: don't leak full stderr to the UI — it can include verbose request
|
|
// dumps with header diagnostics. Truncate to the first non-empty line and
|
|
// cap length so unexpected diagnostics stay readable but bounded.
|
|
const firstLine =
|
|
stderr
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.find((l) => l.length > 0) ?? ''
|
|
const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine
|
|
return {
|
|
type: 'unknown',
|
|
message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.'
|
|
}
|
|
}
|
|
|
|
export function driftError(
|
|
reason: string,
|
|
details?: { path?: (string | number)[]; code?: string }
|
|
): GitHubProjectViewError {
|
|
return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details }
|
|
}
|
|
|
|
// Why: the rate-limit circuit breaker short-circuits before we spawn `gh`
|
|
// when the cached snapshot says we're below the safety floor. Synthesize the
|
|
// same `rate_limited` error shape as the post-hoc classifier so the UI path
|
|
// is unchanged. We DO NOT fail open here when there's no cached snapshot —
|
|
// rateLimitGuard already handles that case (returns `blocked:false`).
|
|
export function rateLimitedError(blocked: {
|
|
remaining: number
|
|
limit: number
|
|
resetAt: number
|
|
}): GitHubProjectViewError {
|
|
const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000))
|
|
const mins = Math.ceil(resetIn / 60)
|
|
return {
|
|
type: 'rate_limited',
|
|
message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.`
|
|
}
|
|
}
|