Files
orca/src/main/gitlab/client-work-items.test.ts
d305e48547 fix(gitlab): guard against non-array API responses in MR/issue listing (#12911)
* 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>
2026-08-10 01:10:12 -07:00

207 lines
7.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GlUtils from './gl-utils'
const {
glabExecFileAsyncMock,
glabApiWithHeadersMock,
getGlabKnownHostsMock,
getProjectRefMock,
resolveIssueSourceMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
glabExecFileAsyncMock: vi.fn(),
glabApiWithHeadersMock: vi.fn(),
getGlabKnownHostsMock: vi.fn(),
getProjectRefMock: vi.fn(),
resolveIssueSourceMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./gl-utils', async () => {
const actual = await vi.importActual<typeof GlUtils>('./gl-utils')
return {
...actual,
glabExecFileAsync: glabExecFileAsyncMock,
glabApiWithHeaders: glabApiWithHeadersMock,
getGlabKnownHosts: getGlabKnownHostsMock,
getProjectRef: getProjectRefMock,
resolveIssueSource: resolveIssueSourceMock,
acquire: acquireMock,
release: releaseMock
}
})
import { listWorkItems } from './client'
describe('gitlab client — combined listWorkItems', () => {
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
glabApiWithHeadersMock.mockReset()
getGlabKnownHostsMock.mockReset()
getProjectRefMock.mockReset()
resolveIssueSourceMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
resolveIssueSourceMock.mockImplementation(async () => ({
source: { host: 'gitlab.com', path: 'g/p' },
fellBack: false
}))
})
it('merges MRs + issues and sorts by updatedAt desc', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 100,
iid: 1,
title: 'older mr',
state: 'opened',
updated_at: '2026-05-05T00:00:00Z',
source_project_id: 5,
target_project_id: 5
}
]),
headers: {}
})
glabExecFileAsyncMock.mockImplementation(async () => {
return {
stdout: JSON.stringify([
{
id: 200,
iid: 5,
title: 'newer issue',
state: 'opened',
updated_at: '2026-05-08T00:00:00Z'
}
])
}
})
const result = await listWorkItems('/repo', 'opened', 1, 20)
expect(result.items.map((i) => i.title)).toEqual(['newer issue', 'older mr'])
expect(result.items[0].type).toBe('issue')
expect(result.items[1].type).toBe('mr')
})
it("skips the issues fetch when state === 'merged'", async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listWorkItems('/repo', 'merged', 1, 20)
// Why: the merged-state filter doesn't apply to issues (issues
// don't have a merged lifecycle), so the IPC must not even spawn
// the issues read. Verifies the listIssues path was not taken.
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('passes the closed state through to the issues fetch', async () => {
glabExecFileAsyncMock.mockImplementation(async () => {
return { stdout: '[]' }
})
await listWorkItems('/repo', 'closed', 1, 20)
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(issuesCallPath.at(-1)).toContain('state=closed')
})
it('passes search queries through to merge request and issue fetches', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'opened', 1, 20, undefined, 'ambiguous selector')
const mergeRequestCallPath = glabApiWithHeadersMock.mock.calls[0][0] as string[]
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(mergeRequestCallPath[0]).toContain('search=ambiguous%20selector')
expect(issuesCallPath.at(-1)).toContain('search=ambiguous%20selector')
})
it('passes the requested page through to merge request and issue fetches', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'opened', 2, 20)
const mergeRequestCallPath = glabApiWithHeadersMock.mock.calls[0][0] as string[]
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
const mergeRequestParams = new URLSearchParams(mergeRequestCallPath[0].split('?')[1])
const issueParams = new URLSearchParams(issuesCallPath.at(-1)?.split('?')[1])
expect(mergeRequestParams.get('page')).toBe('2')
expect(issueParams.get('page')).toBe('2')
})
it("omits the state param when 'all'", async () => {
glabExecFileAsyncMock.mockImplementation(async () => {
return { stdout: '[]' }
})
await listWorkItems('/repo', 'all', 1, 20)
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(issuesCallPath.at(-1)).not.toContain('state=')
})
it('routes issue list fetches through the selected SSH GitLab host', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: { host: 'git.internal', path: 'g/p' },
fellBack: false
})
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'opened', 1, 20, 'upstream', undefined, 'conn-1')
expect(glabExecFileAsyncMock.mock.calls[0][0]).toEqual([
'api',
'--hostname',
'git.internal',
'projects/g%2Fp/issues?page=1&per_page=20&order_by=updated_at&sort=desc&state=opened'
])
})
it('returns a not_found error envelope when project ref is unresolved', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false })
const result = await listWorkItems('/repo', 'opened')
expect(result.error?.type).toBe('not_found')
expect(result.items).toEqual([])
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('surfaces the MR error envelope into the combined result', async () => {
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
const result = await listWorkItems('/repo', 'opened', 1, 20)
expect(result.error?.type).toBe('permission_denied')
})
it('still returns issues when MRs error out', async () => {
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 500'))
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{ id: 200, iid: 9, title: 'live issue', state: 'opened', updated_at: '2026-05-08' }
])
})
const result = await listWorkItems('/repo', 'opened', 1, 20)
expect(result.items).toHaveLength(1)
expect(result.items[0].title).toBe('live issue')
expect(result.error).toBeDefined()
})
it('reports the body instead of ".map is not a function" when the issues fetch returns a non-array', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ data: [], total: 0 })
})
const result = await listWorkItems('/repo', 'opened', 1, 20)
expect(result.items).toEqual([])
expect(result.error?.message).toContain('{"data":[],"total":0}')
expect(result.error?.message).not.toContain('is not a function')
})
})