Files
orca/src/main/gitlab/client-mr.test.ts
T
Brennan Benson aca7d50bab Stop attaching stale closed PRs/MRs to default-branch checkouts (#9469)
* Stop attaching stale closed PRs/MRs to default-branch checkouts

On the repo default branch, the implicit head-branch PR lookup (state=all)
could attach a historical closed/merged PR whose head ref was the default
branch name and show its wrong diffs and checks (#9171).

Add a shared default-branch guard: an implicit branch-name match on the
repository's default branch never surfaces a non-open review. Applied at
the branch-lookup choke point of all five provider clients (GitHub,
GitLab, Bitbucket, Azure DevOps, Gitea). Explicitly linked reviews are
exempt; open reviews from the trunk stay visible; resolution is lazy
(zero git calls unless a non-open candidate appears), TTL-cached,
transport-aware (local/WSL/SSH), probe-time-bounded, and fails open.

* Treat stuck-locked GitLab MRs as non-open in the default-branch guard

Three code-review lanes flagged (one reproduced) that 'locked' — normally
a seconds-long merge transition, but a known GitLab wedge state — leaked
past the closed/merged-only check and would re-create the #9171 symptom
for a stuck-locked MR whose source branch is the trunk.

* Bound default-branch lookup to one refresh budget

* Coalesce default-branch resolution probes
2026-07-20 11:54:30 -07:00

1143 lines
36 KiB
TypeScript

/* eslint-disable max-lines -- Why: GitLab MR operation tests share one hoisted
gl-utils mock; splitting the file would duplicate brittle mock setup. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GlUtils from './gl-utils'
const {
glabExecFileAsyncMock,
glabApiWithHeadersMock,
getGlabKnownHostsMock,
getProjectRefMock,
resolveIssueSourceMock,
acquireMock,
releaseMock,
gitExecFileAsyncMock
} = vi.hoisted(() => ({
glabExecFileAsyncMock: vi.fn(),
glabApiWithHeadersMock: vi.fn(),
getGlabKnownHostsMock: vi.fn(),
getProjectRefMock: vi.fn(),
resolveIssueSourceMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn(),
gitExecFileAsyncMock: vi.fn()
}))
// Why: the #9171 default-branch guard resolves the repo default branch via
// git; keep those probes hermetic instead of spawning real git processes.
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
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 {
_getGitLabRateLimitCacheSize,
_resetGitLabRateLimitCache,
addMRComment,
getMergeRequest,
getMergeRequestForBranch,
getMergeRequestForBranchOrThrow,
getJobTrace,
addMRInlineComment,
closeMR,
diagnoseAuth,
getRateLimit,
listMergeRequests,
listWorkItems,
mergeMR,
reopenMR,
resolveMRDiscussion,
retryJob,
updateMR,
updateMRReviewers
} from './client'
import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch'
/** Answer the real default-branch resolver probes (#9171 guard). */
function primeGitDefaultBranch(defaultRef = 'refs/remotes/origin/main'): void {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'symbolic-ref' && args.includes('refs/remotes/origin/HEAD')) {
return { stdout: `${defaultRef}\n`, stderr: '' }
}
if (args[0] === 'rev-parse' && args[1] === '--verify' && args.includes(defaultRef)) {
return { stdout: 'default-oid\n', stderr: '' }
}
throw new Error(`unexpected git call: ${args.join(' ')}`)
})
}
describe('gitlab client — MR operations', () => {
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
glabApiWithHeadersMock.mockReset()
getGlabKnownHostsMock.mockReset()
getProjectRefMock.mockReset()
resolveIssueSourceMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
gitExecFileAsyncMock.mockReset()
primeGitDefaultBranch()
__resetRepoDefaultBranchCacheForTests()
_resetGitLabRateLimitCache()
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
resolveIssueSourceMock.mockResolvedValue({
source: { host: 'gitlab.com', path: 'g/p' },
fellBack: false
})
})
it('getMergeRequestForBranchOrThrow surfaces a glab failure instead of null (finding 4)', async () => {
getProjectRefMock.mockResolvedValue({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockRejectedValue(new Error('glab: connection refused'))
// The swallowing variant collapses a real failure into a false "no MR".
await expect(getMergeRequestForBranch('/repo', 'feature/x')).resolves.toBeNull()
// The throwing variant makes the failure visible so eligibility records
// `unavailable` rather than a false "No merge request found".
await expect(getMergeRequestForBranchOrThrow('/repo', 'feature/x')).rejects.toThrow(
/connection refused/
)
})
it('routes local WSL MR review-management and job actions through project resolution and glab options', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '{}' })
.mockResolvedValueOnce({ stdout: '{}' })
.mockResolvedValueOnce({ stdout: '{}' })
.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 1,
author: { username: 'alice', avatar_url: '', state: 'active' },
body: 'Comment',
created_at: '2026-06-16T00:00:00.000Z'
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 'discussion-1',
notes: [
{
id: 2,
author: { username: 'alice', avatar_url: '', state: 'active' },
body: 'Inline',
created_at: '2026-06-16T00:00:00.000Z',
position: { new_path: 'src/app.ts', new_line: 12 }
}
]
})
})
.mockResolvedValueOnce({ stdout: '{}' })
.mockResolvedValueOnce({
stdout: JSON.stringify({
reviewers: [{ id: 1, username: 'alice', name: 'Alice', avatar_url: '', state: 'active' }]
})
})
.mockResolvedValueOnce({ stdout: 'trace output' })
.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 100,
pipeline: { id: 50 },
name: 'test',
stage: 'test',
status: 'pending',
web_url: 'https://gitlab.com/g/p/-/jobs/100',
duration: null
})
})
.mockResolvedValueOnce({ stdout: '{}' })
await closeMR('/repo', 12, undefined, null, undefined, localGitOptions)
await reopenMR('/repo', 12, undefined, null, undefined, localGitOptions)
await mergeMR('/repo', 12, 'squash', undefined, null, undefined, localGitOptions)
await addMRComment('/repo', 12, 'Comment', undefined, null, undefined, localGitOptions)
await addMRInlineComment(
'/repo',
12,
{
body: 'Inline',
path: 'src/app.ts',
line: 12,
baseSha: 'base',
startSha: 'start',
headSha: 'head'
},
undefined,
null,
undefined,
localGitOptions
)
await resolveMRDiscussion(
'/repo',
12,
'discussion-1',
true,
undefined,
null,
undefined,
localGitOptions
)
await updateMRReviewers('/repo', 12, [1], undefined, null, undefined, localGitOptions)
await getJobTrace('/repo', 99, undefined, null, undefined, localGitOptions)
await retryJob('/repo', 99, undefined, null, undefined, localGitOptions)
await updateMR('/repo', 12, { title: 'Renamed' }, undefined, null, undefined, localGitOptions)
expect(resolveIssueSourceMock).toHaveBeenCalledWith(
'/repo',
undefined,
['gitlab.com'],
null,
localGitOptions
)
expect(glabExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe(
true
)
})
describe('getMergeRequest', () => {
it('fetches the MR with rolled-up pipeline status', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 10,
title: 'Add feature',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/merge_requests/10',
updated_at: '2026-05-05T00:00:00Z',
sha: 'deadbeef',
head_pipeline: { status: 'success' },
detailed_merge_status: 'mergeable'
})
})
const mr = await getMergeRequest('/repo', 10)
expect(mr).toMatchObject({
number: 10,
title: 'Add feature',
state: 'opened',
url: 'https://gitlab.com/g/p/-/merge_requests/10',
pipelineStatus: 'success',
mergeable: 'MERGEABLE',
headSha: 'deadbeef'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', 'projects/g%2Fp/merge_requests/10'],
{ cwd: '/repo' }
)
})
it('falls back to `glab mr view` when project ref is unresolved', async () => {
getProjectRefMock.mockResolvedValueOnce(null)
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ iid: 5, title: 't', state: 'opened' })
})
await getMergeRequest('/repo', 5)
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['mr', 'view', '5', '--output', 'json'], {
cwd: '/repo'
})
})
it('returns null when glab errors', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not found'))
await expect(getMergeRequest('/repo', 99)).resolves.toBeNull()
})
it('treats neutral pipeline (no head_pipeline) as neutral status', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 1,
title: 't',
state: 'opened',
head_pipeline: null
})
})
const mr = await getMergeRequest('/repo', 1)
expect(mr?.pipelineStatus).toBe('neutral')
})
})
describe('diagnoseAuth', () => {
it('reports glab hosts from auth status', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.com as alice\n',
stderr: ''
})
await expect(diagnoseAuth()).resolves.toMatchObject({
glabAvailable: true,
authenticated: true,
hosts: ['gitlab.com'],
activeHost: 'gitlab.com'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {
allowDefaultWslFallback: false
})
})
})
describe('getRateLimit', () => {
it('parses GitLab REST budget headers', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({
body: '{}',
headers: {
'ratelimit-limit': '2000',
'ratelimit-remaining': '1997',
'ratelimit-reset': '1780000000'
}
})
await expect(
getRateLimit({ host: 'gitlab.example.com', force: true })
).resolves.toMatchObject({
ok: true,
snapshot: {
host: 'gitlab.example.com',
rest: {
limit: 2000,
remaining: 1997,
resetAt: 1780000000
}
}
})
expect(glabApiWithHeadersMock).toHaveBeenCalledWith([
'--hostname',
'gitlab.example.com',
'user'
])
})
it('reports a null bucket when the host omits rate-limit headers', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '{}', headers: {} })
await expect(getRateLimit({ force: true })).resolves.toMatchObject({
ok: true,
snapshot: { host: null, rest: null }
})
})
it('bounds cached rate-limit snapshots across many hosts', async () => {
glabApiWithHeadersMock.mockResolvedValue({ body: '{}', headers: {} })
for (let i = 0; i < 70; i++) {
await getRateLimit({ host: `gitlab-${i}.example.com`, force: true })
}
expect(_getGitLabRateLimitCacheSize()).toBe(64)
})
})
describe('getMergeRequestForBranch', () => {
it('finds the most recently updated MR for a branch across states', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
iid: 7,
title: 'WIP',
state: 'merged',
sha: 'abc',
head_pipeline: { status: 'success' }
}
])
})
const mr = await getMergeRequestForBranch('/repo', 'feature/foo')
expect(mr?.number).toBe(7)
expect(mr?.state).toBe('merged')
expect(mr?.pipelineStatus).toBe('success')
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'projects/g%2Fp/merge_requests?source_branch=feature%2Ffoo&order_by=updated_at&sort=desc&per_page=1'
],
{ cwd: '/repo' }
)
})
it('uses legacy pipeline payloads when branch MR lists omit head_pipeline', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
iid: 8,
title: 'Legacy pipeline branch',
state: 'opened',
sha: 'def',
pipeline: { status: 'failed' }
}
])
})
const mr = await getMergeRequestForBranch('/repo', 'feature/legacy-pipeline')
expect(mr?.number).toBe(8)
expect(mr?.pipelineStatus).toBe('failure')
})
it('strips refs/heads/ prefix from the branch arg', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await getMergeRequestForBranch('/repo', 'refs/heads/feature/bar')
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(callArgs[1]).toContain('source_branch=feature%2Fbar')
})
it('returns null when no MR matches the branch', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await expect(getMergeRequestForBranch('/repo', 'feature')).resolves.toBeNull()
})
it('falls back to a linked MR iid when the branch lookup misses', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 9,
title: 'Linked MR',
state: 'opened',
pipeline: { status: 'success' }
})
})
const mr = await getMergeRequestForBranch('/repo', 'local-review-branch', 9)
expect(mr?.number).toBe(9)
expect(mr?.pipelineStatus).toBe('success')
expect(glabExecFileAsyncMock).toHaveBeenLastCalledWith(
['api', 'projects/g%2Fp/merge_requests/9'],
{ cwd: '/repo' }
)
})
it('preserves merged state when falling back to a linked MR iid', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 10,
title: 'Merged linked MR',
state: 'merged',
pipeline: { status: 'success' }
})
})
const mr = await getMergeRequestForBranch('/repo', 'local-review-branch', 10)
expect(mr).toMatchObject({
number: 10,
state: 'merged',
pipelineStatus: 'success'
})
})
it('routes local WSL merge-request branch lookup through the selected distro', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
iid: 12,
title: 'WSL branch',
state: 'opened',
sha: 'abc',
head_pipeline: { status: 'success' }
}
])
})
const mr = await getMergeRequestForBranch('/repo', 'feature/wsl', null, null, {
localGitExecOptions: { wslDistro: 'Ubuntu' }
})
expect(mr?.number).toBe(12)
expect(getProjectRefMock).toHaveBeenCalledWith('/repo', ['gitlab.com'], null, {
wslDistro: 'Ubuntu'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(expect.any(Array), {
cwd: '/repo',
wslDistro: 'Ubuntu'
})
})
it('hides a stale closed MR whose source branch is the repo default branch (#9171)', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
iid: 7,
title: 'Accidental MR from main',
state: 'closed',
sha: 'stale-main-oid',
head_pipeline: { status: 'success' }
}
])
})
await expect(getMergeRequestForBranch('/repo', 'main')).resolves.toBeNull()
})
it('hides a stuck-locked MR whose source branch is the repo default branch (#9171)', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
iid: 9,
title: 'Wedged mid-merge MR from main',
state: 'locked',
sha: 'locked-main-oid',
head_pipeline: { status: 'success' }
}
])
})
await expect(getMergeRequestForBranch('/repo', 'main')).resolves.toBeNull()
})
it('keeps an open MR whose source branch is the repo default branch', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
iid: 8,
title: 'main → release',
state: 'opened',
sha: 'abc',
head_pipeline: { status: 'success' }
}
])
})
const mr = await getMergeRequestForBranch('/repo', 'main')
expect(mr?.number).toBe(8)
// Open results never consult git for the default branch (lazy resolution).
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('keeps a closed MR on a feature branch (behavior preserved)', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
iid: 9,
title: 'Rejected work',
state: 'closed',
sha: 'def',
head_pipeline: { status: 'failed' }
}
])
})
const mr = await getMergeRequestForBranch('/repo', 'feature/rejected')
expect(mr?.number).toBe(9)
expect(mr?.state).toBe('closed')
})
it('discards a closed default-branch shadow and refetches the linked MR via the fallback (#9171)', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
iid: 7,
title: 'Accidental MR from main',
state: 'closed',
sha: 'stale-main-oid',
head_pipeline: { status: 'success' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 42,
title: 'Linked MR',
state: 'merged',
pipeline: { status: 'success' }
})
})
const mr = await getMergeRequestForBranch('/repo', 'main', 42)
expect(mr).toMatchObject({ number: 42, state: 'merged' })
expect(glabExecFileAsyncMock).toHaveBeenLastCalledWith(
['api', 'projects/g%2Fp/merge_requests/42'],
{ cwd: '/repo' }
)
})
it('keeps a non-open branch match on the default branch when it IS the linked MR', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
iid: 7,
title: 'Linked trunk MR',
state: 'merged',
sha: 'abc',
head_pipeline: { status: 'success' }
}
])
})
const mr = await getMergeRequestForBranch('/repo', 'main', 7)
expect(mr).toMatchObject({ number: 7, state: 'merged' })
// Exempted by linked-number match — no fallback refetch needed.
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('returns null for an empty / detached-HEAD branch arg', async () => {
// Why: during a rebase the branch is empty — mirror github/getPRForBranch's
// early return without calling glab.
await expect(getMergeRequestForBranch('/repo', '')).resolves.toBeNull()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('returns null when project ref cannot be resolved', async () => {
getProjectRefMock.mockResolvedValueOnce(null)
await expect(getMergeRequestForBranch('/repo', 'feature')).resolves.toBeNull()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
})
describe('listMergeRequests', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: { host: 'gitlab.com', path: 'g/p' },
fellBack: false
}))
})
it('returns MRs via the GitLab API', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 100,
iid: 1,
title: 'first',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/merge_requests/1',
updated_at: '2026-05-05',
source_branch: 'feat-1',
target_branch: 'main',
author: { username: 'alice' },
source_project_id: 5,
target_project_id: 5
}
]),
headers: { 'x-total': '1', 'x-total-pages': '1' }
})
const result = await listMergeRequests('/repo', 'opened', 1, 20)
expect(result.items).toHaveLength(1)
expect(result.items[0]).toMatchObject({
type: 'mr',
number: 1,
title: 'first',
state: 'opened',
branchName: 'feat-1',
baseRefName: 'main',
author: 'alice',
isCrossRepository: false,
repoId: 'g/p'
})
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
[
'projects/g%2Fp/merge_requests?page=1&per_page=20&order_by=updated_at&sort=desc&with_merge_status_recheck=false&state=opened'
],
{ cwd: '/repo' }
)
})
it('routes local WSL MR listing through project resolution and glab API options', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', 'opened', 1, 20, undefined, undefined, null, localGitOptions)
expect(resolveIssueSourceMock).toHaveBeenCalledWith(
'/repo',
undefined,
['gitlab.com'],
null,
localGitOptions
)
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
[
'projects/g%2Fp/merge_requests?page=1&per_page=20&order_by=updated_at&sort=desc&with_merge_status_recheck=false&state=opened'
],
{ cwd: '/repo', wslDistro: 'Ubuntu' }
)
})
it('routes local WSL combined work-item listing through MR and issue glab options', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'opened', 1, 20, undefined, undefined, null, localGitOptions)
expect(resolveIssueSourceMock).toHaveBeenCalledWith(
'/repo',
undefined,
['gitlab.com'],
null,
localGitOptions
)
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({ cwd: '/repo', wslDistro: 'Ubuntu' })
)
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({ cwd: '/repo', wslDistro: 'Ubuntu' })
)
})
it("omits state when state='all'", async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', 'all', 1, 20)
const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[]
expect(callArgs[0]).not.toContain('state=')
})
it('passes through Open / Merged / Closed states as API params', async () => {
for (const state of ['opened', 'merged', 'closed'] as const) {
glabApiWithHeadersMock.mockReset()
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', state, 1, 20)
const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[]
expect(callArgs[0]).toContain(`state=${state}`)
}
})
it('appends an encoded &search= param when a query is supplied', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', 'opened', 1, 20, undefined, 'fix login')
const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[]
expect(callArgs[0]).toContain('&search=fix%20login')
})
it('omits &search= for an empty or whitespace-only query', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', 'opened', 1, 20, undefined, ' ')
const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[]
expect(callArgs[0]).not.toContain('search=')
})
it('flags fork MRs as cross-repository', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 200,
iid: 2,
title: 'fork mr',
state: 'opened',
source_branch: 'feat',
target_branch: 'main',
source_project_id: 11,
target_project_id: 5
}
]),
headers: {}
})
const result = await listMergeRequests('/repo', 'opened', 1, 20)
expect(result.items[0].isCrossRepository).toBe(true)
})
it('falls back to glab mr list when project ref is unresolved', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: null,
fellBack: false
})
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
id: 300,
iid: 3,
title: 'fallback mr',
state: 'opened',
web_url: 'https://gitlab.com/-/merge_requests/3',
updated_at: '2026-05-05',
source_project_id: 5,
target_project_id: 5
}
])
})
const result = await listMergeRequests('/repo', 'opened')
expect(result.items).toHaveLength(1)
expect(result.items[0].title).toBe('fallback mr')
expect(glabApiWithHeadersMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'mr',
'list',
'--output',
'json',
'--per-page',
'20',
'--page',
'1',
'--order',
'updated_at',
'--sort',
'desc'
],
{ cwd: '/repo' }
)
})
it('threads --search into the cwd fallback when a query is supplied', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: null,
fellBack: false
})
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listMergeRequests('/repo', 'opened', 1, 20, undefined, 'fix login')
expect(glabApiWithHeadersMock).not.toHaveBeenCalled()
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
// Why (#6263): the cwd-inferred fallback must honor the typed query too.
const searchIdx = callArgs.indexOf('--search')
expect(searchIdx).toBeGreaterThanOrEqual(0)
expect(callArgs[searchIdx + 1]).toBe('fix login')
})
it('omits --search from the cwd fallback for a whitespace-only query', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: null,
fellBack: false
})
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listMergeRequests('/repo', 'opened', 1, 20, undefined, ' ')
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(callArgs).not.toContain('--search')
})
it('classifies fallback errors into the result envelope', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: null,
fellBack: false
})
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
const result = await listMergeRequests('/repo', 'opened')
expect(result.error?.type).toBe('permission_denied')
expect(result.items).toEqual([])
expect(glabApiWithHeadersMock).not.toHaveBeenCalled()
})
it('does not run the cwd fallback for unresolved SSH repos', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: null,
fellBack: false
})
const result = await listMergeRequests(
'/remote/repo',
'opened',
1,
20,
undefined,
undefined,
'conn-1'
)
expect(result.error?.type).toBe('not_found')
expect(result.items).toEqual([])
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
expect(glabApiWithHeadersMock).not.toHaveBeenCalled()
})
it('classifies API errors into the result envelope', async () => {
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
const result = await listMergeRequests('/repo', 'opened')
expect(result.error?.type).toBe('permission_denied')
expect(result.items).toEqual([])
})
})
describe('updateMR', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: { host: 'git.internal', path: 'g/p' },
fellBack: false
}))
})
it('updates title, body, and labels through the selected SSH GitLab host', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' })
await expect(
updateMR(
'/repo',
12,
{
title: 'Renamed',
body: 'Updated body',
addLabels: ['bug'],
removeLabels: ['stale']
},
'upstream',
'conn-1'
)
).resolves.toEqual({ ok: true })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'--hostname',
'git.internal',
'-X',
'PUT',
'projects/g%2Fp/merge_requests/12',
'-f',
'title=Renamed',
'-f',
'description=Updated body',
'-f',
'add_labels=bug',
'-f',
'remove_labels=stale'
],
{}
)
})
})
describe('resolveMRDiscussion', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: { host: 'git.internal', path: 'g/p' },
fellBack: false
}))
})
it('updates the discussion resolved state through the selected SSH GitLab host', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' })
await expect(
resolveMRDiscussion('/repo', 12, 'discussion-1', true, 'upstream', 'conn-1')
).resolves.toEqual({ ok: true })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'--hostname',
'git.internal',
'-X',
'PUT',
'projects/g%2Fp/merge_requests/12/discussions/discussion-1',
'-f',
'resolved=true'
],
{}
)
})
it('rejects an empty discussion id without calling glab', async () => {
await expect(resolveMRDiscussion('/repo', 12, ' ', true)).resolves.toEqual({
ok: false,
error: 'Discussion id is required'
})
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
})
describe('addMRInlineComment', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: { host: 'git.internal', path: 'g/p' },
fellBack: false
}))
})
it('posts an inline discussion with GitLab position fields', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 'discussion-1',
notes: [
{
id: 500,
author: { username: 'alice', avatar_url: 'https://example.com/a.png' },
body: 'please fix',
created_at: '2026-05-05T10:00:00Z',
position: { new_path: 'src/app.ts', new_line: 12 }
}
]
})
})
await expect(
addMRInlineComment(
'/repo',
12,
{
body: 'please fix',
path: 'src/app.ts',
line: 12,
baseSha: 'base',
startSha: 'start',
headSha: 'head'
},
'upstream',
'conn-1'
)
).resolves.toMatchObject({
ok: true,
comment: {
id: 500,
threadId: 'discussion-1',
path: 'src/app.ts',
line: 12
}
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'--hostname',
'git.internal',
'-X',
'POST',
'projects/g%2Fp/merge_requests/12/discussions',
'-f',
'body=please fix',
'-f',
'position[position_type]=text',
'-f',
'position[base_sha]=base',
'-f',
'position[start_sha]=start',
'-f',
'position[head_sha]=head',
'-f',
'position[old_path]=src/app.ts',
'-f',
'position[new_path]=src/app.ts',
'-f',
'position[new_line]=12'
],
{}
)
})
})
describe('job CI operations', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: { host: 'git.internal', path: 'g/p' },
fellBack: false
}))
})
it('fetches a job trace through the selected SSH GitLab host', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'trace output' })
await expect(getJobTrace('/repo', 99, 'upstream', 'conn-1')).resolves.toEqual({
ok: true,
trace: 'trace output'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--hostname', 'git.internal', 'projects/g%2Fp/jobs/99/trace'],
{}
)
})
it('retries a job through the selected SSH GitLab host', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 100,
pipeline: { id: 50 },
name: 'test',
stage: 'test',
status: 'pending',
web_url: 'https://git.internal/g/p/-/jobs/100',
duration: null
})
})
await expect(retryJob('/repo', 99, 'upstream', 'conn-1')).resolves.toEqual({
ok: true,
job: {
id: 100,
pipelineId: 50,
name: 'test',
stage: 'test',
status: 'pending',
webUrl: 'https://git.internal/g/p/-/jobs/100',
duration: null
}
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--hostname', 'git.internal', '-X', 'POST', 'projects/g%2Fp/jobs/99/retry'],
{}
)
})
})
describe('updateMRReviewers', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: { host: 'git.internal', path: 'g/p' },
fellBack: false
}))
})
it('sets reviewers through reviewer_ids on the selected SSH GitLab host', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
reviewers: [
{
id: 1,
username: 'alice',
name: 'Alice',
avatar_url: 'https://example.com/a.png',
state: 'active'
}
]
})
})
await expect(updateMRReviewers('/repo', 12, [1], 'upstream', 'conn-1')).resolves.toEqual({
ok: true,
reviewers: [
{
id: 1,
username: 'alice',
name: 'Alice',
avatarUrl: 'https://example.com/a.png',
state: 'active'
}
]
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'--hostname',
'git.internal',
'-X',
'PUT',
'projects/g%2Fp/merge_requests/12',
'-f',
'reviewer_ids[]=1'
],
{}
)
})
})
})