mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* fix(gitlab): guard against non-array API responses in MR/issue listing fetchIssuesAsWorkItems and listMergeRequests parsed glab's JSON output and called .map straight on it. When the GitLab API returns a JSON object instead of an array (error body, unexpected shape) on a successful exit, this crashed with a bare TypeError that got misclassified as "Failed to load issues: JSON.parse(...).map is not a function" instead of a useful message. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(gitlab): cover listIssues and keep payloads out of error classification The guard missed listIssues in issues.ts — the RPC-backed issue list that produces the reported "Failed to load issues: JSON.parse(...).map is not a function". Hoist the guard into glab-api-response.ts so both files share it. The thrown message is fed to classifyGlabError, which substring-matches it. A response payload is content, not a diagnostic: an MR titled "fix network timeout" classified as network_error and the canned copy replaced the payload the user needed. Report a GitLab error envelope by its own message, and mark an opaque body so classification is skipped. * test(gitlab): make the list-guard tests fail on the regressions they name Two assertions were vacuous under mutation. The envelope test used a "403 Forbidden" message whose keyword matches earlier in the classifier chain than its sibling payload, so leaking the payload into classification still passed; it now uses a 404 envelope beside a "403 forbidden" sibling. No call-site test carried a classifier keyword, so deleting the marker-error branch entirely failed only one unit test; the MR API path now uses a keyword-bearing body. Also give the non-list branch the same "Failed to load issues" prefix as every other list error, cover the `{ error }` envelope field, and pin the thrown type. * test(gitlab): pin the reported-payload bound Removing the 300-char slice survived the whole suite, and the banner's break-words now depends on it. Name the limit and assert both branches truncate, plus the envelope falling through a blank message to `error`. * test(gitlab): pin message-over-error envelope precedence Swapping the lookup order passed the whole suite. Anchor the bound regex too so it cannot match an incidental ": " near the end of a message. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
89 lines
3.1 KiB
TypeScript
89 lines
3.1 KiB
TypeScript
export type GlabApiResponse = {
|
|
body: string
|
|
headers: Record<string, string>
|
|
}
|
|
|
|
/** @internal - exported for tests through gl-utils. */
|
|
export function parseGlabApiResponse(stdout: string): GlabApiResponse {
|
|
// Why: response is HTTP status, headers, blank line, then body.
|
|
// Find the first blank line (CRLF or LF) as the boundary.
|
|
const separator = findHeaderBodySeparator(stdout)
|
|
if (!separator) {
|
|
return { body: stdout, headers: {} }
|
|
}
|
|
const headerBlock = stdout.slice(0, separator.index)
|
|
const body = stdout.slice(separator.bodyStart)
|
|
const headers: Record<string, string> = {}
|
|
// Skip the status line and parse the rest as key: value.
|
|
const lines = headerBlock.split(/\r?\n/)
|
|
for (const line of lines) {
|
|
const m = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/)
|
|
if (m) {
|
|
headers[m[1].toLowerCase()] = m[2].trim()
|
|
}
|
|
}
|
|
return { body, headers }
|
|
}
|
|
|
|
/** A non-list body carrying no GitLab error text — opaque data, so there is nothing to classify. */
|
|
export class GlabNonListResponseError extends Error {}
|
|
|
|
// Why: glab allows a 10MB body; this is what keeps a proxy's whole response out of the error banner.
|
|
const REPORTED_PAYLOAD_LIMIT = 300
|
|
|
|
/**
|
|
* Parse a glab list response, failing readably when GitLab answers with a JSON object.
|
|
*
|
|
* Why: glab exits 0 on error envelopes and proxy wrappers, so `JSON.parse(...).map` threw an
|
|
* opaque `.map is not a function` that the caller's classifier could only report as "unknown".
|
|
*/
|
|
export function parseGlabJsonList<T>(payload: string): T[] {
|
|
const parsed: unknown = JSON.parse(payload)
|
|
if (Array.isArray(parsed)) {
|
|
return parsed as T[]
|
|
}
|
|
const reported = gitlabErrorText(parsed)
|
|
if (reported) {
|
|
throw new Error(`GitLab returned an error: ${reported}`)
|
|
}
|
|
// Why: slice the raw payload rather than re-serializing `parsed` — same text, without
|
|
// stringifying a multi-megabyte body just to keep the preview.
|
|
throw new GlabNonListResponseError(
|
|
`GitLab returned a non-list response: ${payload.trim().slice(0, REPORTED_PAYLOAD_LIMIT)}`
|
|
)
|
|
}
|
|
|
|
/** GitLab reports API failures as `{ message }` or `{ error }`; anything else is opaque data. */
|
|
function gitlabErrorText(parsed: unknown): string | null {
|
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
return null
|
|
}
|
|
const { message, error } = parsed as { message?: unknown; error?: unknown }
|
|
for (const value of [message, error]) {
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return value.trim().slice(0, REPORTED_PAYLOAD_LIMIT)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function findHeaderBodySeparator(stdout: string): { index: number; bodyStart: number } | null {
|
|
let lineStart = 0
|
|
for (let index = 0; index < stdout.length; index++) {
|
|
const code = stdout.charCodeAt(index)
|
|
if (code !== 10 && code !== 13) {
|
|
continue
|
|
}
|
|
|
|
const lineEnd = index
|
|
const nextLineStart =
|
|
stdout.charCodeAt(index) === 13 && stdout.charCodeAt(index + 1) === 10 ? index + 2 : index + 1
|
|
if (lineEnd === lineStart) {
|
|
return { index: lineStart, bodyStart: nextLineStart }
|
|
}
|
|
lineStart = nextLineStart
|
|
index = nextLineStart - 1
|
|
}
|
|
return null
|
|
}
|