mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* fix(repos): preserve unknown Git availability * fix(git): distinguish binary absence from missing cwd on spawn ENOENT Node reports ENOENT for both a missing git binary and a missing working directory during spawn. The fix checks specifically for spawn syscall, then verifies the cwd exists to disambiguate. This prevents reporting "no Git" when the error is actually a missing working directory. Centralizes probe logic in a reusable function; other failures cause rejection so callers preserve the unknown status instead of collapsing to false.
125 lines
4.0 KiB
TypeScript
125 lines
4.0 KiB
TypeScript
/**
|
|
* Pure helpers for reading diagnostics out of a subprocess (git/gh/glab)
|
|
* rejection. Kept in a standalone module (not `runner.ts`) so lightweight
|
|
* consumers — e.g. PR-refresh error classification — can import them without
|
|
* pulling in the runner's heavy execution machinery, and so tests that mock
|
|
* `./runner` still get the real implementations.
|
|
*/
|
|
|
|
/**
|
|
* Extract stderr/stdout from an execFile rejection.
|
|
*
|
|
* Why: Node's execFile rejects with an Error that has `.stdout` and `.stderr`
|
|
* fields populated separately from `.message`. Reading `err.message` alone is
|
|
* unreliable — it can truncate stderr or omit it entirely depending on Node
|
|
* version and maxBuffer behavior. We prefer the explicit fields and fall
|
|
* back to `.message` only when neither is present.
|
|
*/
|
|
export function extractExecError(err: unknown): { stderr: string; stdout: string } {
|
|
if (err && typeof err === 'object') {
|
|
const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown }
|
|
const stderr =
|
|
typeof e.stderr === 'string'
|
|
? e.stderr
|
|
: Buffer.isBuffer(e.stderr)
|
|
? e.stderr.toString('utf-8')
|
|
: ''
|
|
const stdout =
|
|
typeof e.stdout === 'string'
|
|
? e.stdout
|
|
: Buffer.isBuffer(e.stdout)
|
|
? e.stdout.toString('utf-8')
|
|
: ''
|
|
if (stderr || stdout) {
|
|
return { stderr, stdout }
|
|
}
|
|
if (typeof e.message === 'string') {
|
|
return { stderr: e.message, stdout: '' }
|
|
}
|
|
}
|
|
return { stderr: String(err), stdout: '' }
|
|
}
|
|
|
|
/** Recognizes spawn ENOENT; callers must separately rule out a missing cwd. */
|
|
export function isMissingCommandBinaryError(err: unknown): boolean {
|
|
return Boolean(
|
|
err &&
|
|
typeof err === 'object' &&
|
|
'code' in err &&
|
|
err.code === 'ENOENT' &&
|
|
'syscall' in err &&
|
|
typeof err.syscall === 'string' &&
|
|
err.syscall.startsWith('spawn ')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Detect a Retry-After hint in gh stderr and return the suggested delay in ms,
|
|
* or null when the response includes no Retry-After.
|
|
*
|
|
* Why: gh forwards response headers when verbose, and prints "Retry-After:
|
|
* <seconds>" in error output for primary rate-limit 429s. When present, the
|
|
* caller is better served by propagating the error so the UI can surface the
|
|
* real wait time — retrying on our own 250ms cadence just earns another 429
|
|
* and burns the retry budget. Also supports HTTP-date Retry-After values.
|
|
*/
|
|
export function parseRetryAfterMs(stderr: string): number | null {
|
|
const raw = findRetryAfterHeaderValue(stderr)
|
|
if (raw === null) {
|
|
return null
|
|
}
|
|
if (/^\d+$/.test(raw)) {
|
|
const seconds = Number(raw)
|
|
return Number.isFinite(seconds) ? seconds * 1000 : null
|
|
}
|
|
const ts = Date.parse(raw)
|
|
if (Number.isNaN(ts)) {
|
|
return null
|
|
}
|
|
return Math.max(0, ts - Date.now())
|
|
}
|
|
|
|
function findRetryAfterHeaderValue(stderr: string): string | null {
|
|
const headerIndex = indexOfAsciiIgnoreCase(stderr, 'retry-after:', 0)
|
|
if (headerIndex === -1) {
|
|
return null
|
|
}
|
|
let valueStart = headerIndex + 'retry-after:'.length
|
|
while (valueStart < stderr.length) {
|
|
const code = stderr.charCodeAt(valueStart)
|
|
if (code !== 9 && code !== 32) {
|
|
break
|
|
}
|
|
valueStart++
|
|
}
|
|
let valueEnd = valueStart
|
|
while (valueEnd < stderr.length) {
|
|
const code = stderr.charCodeAt(valueEnd)
|
|
if (code === 10 || code === 13) {
|
|
break
|
|
}
|
|
valueEnd++
|
|
}
|
|
const value = stderr.slice(valueStart, valueEnd).trim()
|
|
return value.length > 0 ? value : null
|
|
}
|
|
|
|
function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number {
|
|
const lastStart = value.length - search.length
|
|
for (let index = Math.max(0, fromIndex); index <= lastStart; index++) {
|
|
let matches = true
|
|
for (let offset = 0; offset < search.length; offset++) {
|
|
const code = value.charCodeAt(index + offset)
|
|
const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code
|
|
if (normalizedCode !== search.charCodeAt(offset)) {
|
|
matches = false
|
|
break
|
|
}
|
|
}
|
|
if (matches) {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|