mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03: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>
207 lines
7.4 KiB
TypeScript
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')
|
|
})
|
|
})
|