orca/src/main/gitlab/client.test.ts

283 lines
9.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GlUtils from './gl-utils'
const {
glabExecFileAsyncMock,
getGlabKnownHostsMock,
getProjectRefMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
glabExecFileAsyncMock: vi.fn(),
getGlabKnownHostsMock: vi.fn(),
getProjectRefMock: 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,
getGlabKnownHosts: getGlabKnownHostsMock,
getProjectRef: getProjectRefMock,
acquire: acquireMock,
release: releaseMock
}
})
import { getAuthenticatedViewer, getWorkItemByProjectRef, listTodos } from './client'
describe('gitlab client — viewer & paste-URL lookup', () => {
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
getGlabKnownHostsMock.mockReset()
getProjectRefMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
getProjectRefMock.mockResolvedValue(null)
})
describe('getAuthenticatedViewer', () => {
it('returns username + email when glab api user succeeds', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ username: 'alice', email: 'alice@example.com' })
})
await expect(getAuthenticatedViewer()).resolves.toEqual({
username: 'alice',
email: 'alice@example.com'
})
})
it('coerces a missing email to null', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ username: 'alice', email: null })
})
await expect(getAuthenticatedViewer()).resolves.toEqual({
username: 'alice',
email: null
})
})
it('returns null when glab fails', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not authenticated'))
await expect(getAuthenticatedViewer()).resolves.toBeNull()
})
it('returns null when username is empty', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ username: ' ', email: null })
})
await expect(getAuthenticatedViewer()).resolves.toBeNull()
})
})
describe('getWorkItemByProjectRef', () => {
it('fetches an MR and maps to GitLabWorkItem', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 100,
iid: 5,
title: 't',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/merge_requests/5',
source_branch: 'feat',
target_branch: 'main'
})
})
const item = await getWorkItemByProjectRef(
'/repo',
{ host: 'gitlab.com', path: 'g/p' },
5,
'mr'
)
expect(item).toMatchObject({ type: 'mr', number: 5, branchName: 'feat' })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--hostname', 'gitlab.com', 'projects/g%2Fp/merge_requests/5'],
{ cwd: '/repo' }
)
})
it('fetches an issue and maps to GitLabWorkItem', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 200,
iid: 9,
title: 'bug',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/issues/9'
})
})
const item = await getWorkItemByProjectRef(
'/repo',
{ host: 'gitlab.com', path: 'g/p' },
9,
'issue'
)
expect(item).toMatchObject({ type: 'issue', number: 9 })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--hostname', 'gitlab.com', 'projects/g%2Fp/issues/9'],
{ cwd: '/repo' }
)
})
it('passes self-hosted hostname for local pasted URL lookups', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 201,
iid: 9,
title: 'bug',
state: 'opened',
web_url: 'https://gitlab.internal/g/p/-/issues/9'
})
})
await getWorkItemByProjectRef('/repo', { host: 'gitlab.internal', path: 'g/p' }, 9, 'issue')
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--hostname', 'gitlab.internal', 'projects/g%2Fp/issues/9'],
{ cwd: '/repo' }
)
})
it('routes local WSL pasted-project lookup through glab execution options', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 201,
iid: 9,
title: 'WSL bug',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/issues/9'
})
})
await getWorkItemByProjectRef(
'/repo',
{ host: 'gitlab.com', path: 'g/p' },
9,
'issue',
null,
localGitOptions
)
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--hostname', 'gitlab.com', 'projects/g%2Fp/issues/9'],
{
cwd: '/repo',
wslDistro: 'Ubuntu'
}
)
})
it('returns null when the API errors', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not found'))
const item = await getWorkItemByProjectRef(
'/repo',
{ host: 'gitlab.com', path: 'g/p' },
9,
'issue'
)
expect(item).toBeNull()
})
})
describe('listTodos', () => {
it('maps glab todos response to GitLabTodo shape', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
id: 1,
action_name: 'assigned',
target_type: 'MergeRequest',
target: {
iid: 42,
title: 'Add feature',
web_url: 'https://gitlab.com/g/p/-/merge_requests/42'
},
target_url: 'https://gitlab.com/g/p/-/merge_requests/42',
author: { username: 'alice', avatar_url: 'https://example.com/a.png' },
project: { path_with_namespace: 'g/p' },
updated_at: '2026-05-08T10:00:00Z',
state: 'pending'
}
])
})
await expect(listTodos('/repo')).resolves.toEqual([
{
id: 1,
actionName: 'assigned',
targetType: 'MergeRequest',
targetIid: 42,
targetTitle: 'Add feature',
targetUrl: 'https://gitlab.com/g/p/-/merge_requests/42',
projectPath: 'g/p',
authorUsername: 'alice',
authorAvatarUrl: 'https://example.com/a.png',
updatedAt: '2026-05-08T10:00:00Z',
state: 'pending'
}
])
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', 'todos?state=pending&per_page=50'],
{ cwd: '/repo' }
)
})
it('routes local WSL todos through project resolution and glab execution options', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await expect(listTodos('/repo', null, localGitOptions)).resolves.toEqual([])
expect(getProjectRefMock).toHaveBeenCalledWith('/repo', ['gitlab.com'], null, localGitOptions)
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', 'todos?state=pending&per_page=50'],
{ cwd: '/repo', wslDistro: 'Ubuntu' }
)
})
it('coerces non-pending state values to pending (defensive)', async () => {
// Why: we filter to state=pending in the request, but if a future
// glab change leaks a different state through, the type's narrow
// 'pending' | 'done' union should still hold — anything not 'done'
// collapses to 'pending' rather than violating the type.
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{ id: 2, action_name: 'mentioned', target_type: 'Issue', state: 'weird' }
])
})
const result = await listTodos('/repo')
expect(result[0].state).toBe('pending')
})
it('falls back to empty list when glab errors', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('auth failed'))
await expect(listTodos('/repo')).resolves.toEqual([])
})
it('handles missing target / project / author fields gracefully', async () => {
// Why: GitLab Todos for Commit / Note targets sometimes omit
// `target` entirely — defaults must keep the record well-formed
// so the renderer doesn't choke on .title access.
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([{ id: 3, action_name: 'build_failed', target_type: 'Commit' }])
})
const result = await listTodos('/repo')
expect(result[0]).toMatchObject({
targetIid: null,
targetTitle: '',
targetUrl: '',
projectPath: '',
authorUsername: '',
authorAvatarUrl: ''
})
})
})
})