/* eslint-disable max-lines -- Why: shared infrastructure for project-view — slug validation, error classification, runGraphql/runRest, and rate-limit synthesis. Co-located so the read and write paths observe identical classification semantics. */ // Why: `ghExecFileAsync` (WSL-aware, retry-enabled) is the single spawn site // for gh calls. The legacy plain `execFileAsync` is NOT used here — routing // every gh call through the runner gives us transient-5xx retry, WSL path // translation, and a single hook point for future quota tracking. import { acquire, release } from '../gh-utils' import { extractExecError, ghExecFileAsync } from '../../git/runner' import { rateLimitGuard, noteRateLimitSpend, type RateLimitBucketKind } from '../rate-limit' import type { GitHubProjectViewError } from '../../../shared/github-project-types' export { acquire, release, extractExecError, ghExecFileAsync, rateLimitGuard, noteRateLimitSpend } export type { RateLimitBucketKind } // ─── Slug validation ────────────────────────────────────────────────── // Why: GitHub usernames/org logins disallow `_`, `.`, leading `-`. Repo names // are looser — they allow leading `_`, `.`, `-` (`.` and `..` reserved). We // validate each separately so untrusted Project row data (`nameWithOwner`) // can't become an arbitrary REST path while still accepting realistic repo // names like `_internal` or `.github`. const OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ const REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ const REPO_SLUG_RESERVED = new Set(['.', '..']) export function isValidOwnerSlug(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && OWNER_SLUG_RE.test(value) } export function isValidRepoSlug(value: unknown): value is string { return ( typeof value === 'string' && value.length > 0 && REPO_SLUG_RE.test(value) && !REPO_SLUG_RESERVED.has(value) ) } // Backwards-compatible alias for callers that don't distinguish owner vs repo. // Prefer `isValidOwnerSlug` / `isValidRepoSlug` at new call sites. export function isValidSlug(value: unknown): value is string { return isValidOwnerSlug(value) || isValidRepoSlug(value) } export function assertSlug( value: unknown, field: 'owner' | 'repo' ): { ok: true; slug: string } | { ok: false; error: GitHubProjectViewError } { const valid = field === 'owner' ? isValidOwnerSlug(value) : isValidRepoSlug(value) if (!valid) { return { ok: false, error: { type: 'validation_error', message: `Invalid ${field}: "${String(value)}" is not a valid GitHub slug.` } } } return { ok: true, slug: value as string } } export function assertPositiveInt( value: unknown, field: string ): { ok: true; n: number } | { ok: false; error: GitHubProjectViewError } { if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { return { ok: false, error: { type: 'validation_error', message: `Invalid ${field}: must be a positive integer.` } } } return { ok: true, n: value } } export function validateSlugArgs( owner: unknown, repo: unknown ): { ok: true } | { ok: false; error: GitHubProjectViewError } { const o = assertSlug(owner, 'owner') if (!o.ok) { return { ok: false, error: o.error } } const r = assertSlug(repo, 'repo') if (!r.ok) { return { ok: false, error: r.error } } return { ok: true } } // ─── Error classification ────────────────────────────────────────────── 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') || s.includes('could not resolve to a ') ) { 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.` } } // ─── Low-level gh api graphql invocation ─────────────────────────────── export type GraphqlVars = Record export async function runGraphql( query: string, vars: GraphqlVars, cwd?: string ): Promise< | { ok: true; data: T } | { ok: false; error: GitHubProjectViewError; raw: { stderr: string; stdout: string } } > { const guard = rateLimitGuard('graphql') if (guard.blocked) { return { ok: false, error: rateLimitedError(guard), raw: { stderr: '', stdout: '' } } } // Why: build argv as an array. `-f` for strings (including numbers passed // as strings), `-F` coerces to typed. We use `-f` uniformly and coerce in // the query via Int! casts, because `gh` can confuse empty strings. const args: string[] = ['api', 'graphql', '-f', `query=${query}`] for (const [k, v] of Object.entries(vars)) { if (typeof v === 'number' || typeof v === 'boolean') { args.push('-F', `${k}=${String(v)}`) } else { args.push('-f', `${k}=${v}`) } } await acquire() noteRateLimitSpend('graphql') try { const { stdout, stderr } = await ghExecFileAsync(args, { encoding: 'utf-8', ...(cwd ? { cwd } : {}) }) try { const parsed = JSON.parse(stdout) as { data?: T; errors?: GhGraphqlErrorShape[] } if (parsed.errors && parsed.errors.length > 0) { return { ok: false, error: classifyProjectError(stderr, stdout), raw: { stderr, stdout } } } if (parsed.data === undefined) { return { ok: false, error: driftError('response missing data'), raw: { stderr, stdout } } } return { ok: true, data: parsed.data } } catch (parseErr) { return { ok: false, error: driftError( `failed to parse response (${parseErr instanceof Error ? parseErr.message : String(parseErr)})` ), raw: { stderr, stdout } } } } catch (err) { // gh executable failures (non-zero exit). Read stderr/stdout from the // exec rejection's explicit fields — `err.message` may truncate stderr. const { stderr, stdout: maybeStdout } = extractExecError(err) return { ok: false, error: classifyProjectError(stderr, maybeStdout), raw: { stderr, stdout: maybeStdout } } } finally { release() } } export async function runRest( args: string[], cwd?: string, bucket: RateLimitBucketKind = 'core', options?: { expectEmpty?: boolean } ): Promise<{ ok: true; data: T } | { ok: false; error: GitHubProjectViewError }> { const guard = rateLimitGuard(bucket) if (guard.blocked) { return { ok: false, error: rateLimitedError(guard) } } await acquire() noteRateLimitSpend(bucket) try { const { stdout, stderr } = await ghExecFileAsync(['api', ...args], { encoding: 'utf-8', ...(cwd ? { cwd } : {}) }) // Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no // body. Treat empty stdout as success rather than misclassifying the // unparseable response as 'unknown' — which the caller would otherwise // need to special-case and risks masking real failures whose stderr the // classifier also tags as 'unknown'. if (options?.expectEmpty && stdout.trim() === '') { return { ok: true, data: undefined as T } } try { return { ok: true, data: JSON.parse(stdout) as T } } catch { return { ok: false, error: { type: 'unknown', message: `Unexpected REST response: ${stderr.trim()}` } } } } catch (err) { const { stderr, stdout: maybeStdout } = extractExecError(err) return { ok: false, error: classifyProjectError(stderr, maybeStdout) } } finally { release() } }