Files
orca/src/main/gitlab/gl-utils.test.ts
T
a10e1d7584 fix(gitlab): recognize self-hosted GitLab on non-default ports over SSH connections; stop one project failing the whole issues panel (#5400)
* fix(gitlab): port-aware self-hosted host recognition

Use the URL host (including a non-default web/API port) as the GitLab
host identity instead of the port-less hostname, and match known hosts
port-aware:

- A known-host entry without a port matches any port of the same
  hostname (preserves legacy bare-host and gitlab.com recognition).
- A known-host entry WITH a port matches only that exact host:port, so
  two services sharing a hostname on different ports (e.g. a GitLab and
  a Gitea) are no longer conflated.
- For ssh/git remotes the port is a transport port (e.g. ssh :2222) and
  is dropped; for http(s) remotes the port is the endpoint and kept.
- Also capture an optional :port in parseGlabAuthStatusHosts so a
  self-hosted GitLab on a non-default port is discovered correctly.

* fix(gitlab): per-connection known-hosts cache + port-aware auth-status parsing

getGlabKnownHosts() was connection-blind and cached process-globally,
and on any failure it cached [gitlab.com] forever — so a repo on an SSH
connection never discovered its self-hosted host once a probe failed
before the tunnel was ready.

- getGlabKnownHosts(connectionId?) now caches per connection so a
  connected repo's authenticated hosts don't leak into the local
  context (or vice versa).
- The failure fallback (canonical default) is no longer cached, so a
  later probe can re-discover the real host once auth/tunnel is ready.
- parseGlabAuthStatusHosts captures an optional :port on both the
  'Logged in to <host>' and header-style lines, keeping two services on
  the same hostname distinct by port.

* fix(gitlab): isolate unresolvable projects instead of cwd-fallback that hits exit 128

listIssues/getIssue fell back to an unscoped 'glab issue list' / 'glab
issue view' that infers the project from cwd. For a repo on an SSH
connection cwd is not the repo dir, so glab runs git resolution in a
non-repo dir and fails with 'git: exit status 128'. In an 'All projects'
aggregate one such failure could sink the whole issues panel.

When a projectRef cannot be resolved, return a structured, isolated
per-project result (listIssues: { items: [], error: not_found };
getIssue: null) and spawn no glab subprocess. Behavior is unchanged when
a projectRef IS resolved (the scoped '-R' / 'api projects/...' path).

* fix(gitlab): recognize modern /-/work_items/<iid> issue URLs

Modern GitLab emits issue URLs as /-/work_items/<iid> in addition to the
legacy /-/issues/<iid>. The URL classifiers only matched /-/issues/, so
work-item-form issue links went unrecognized.

Extend the gitlab-links parsers (parseGitLabIssueOrMRNumber /
parseGitLabIssueOrMRLink, which also backs isWorkItemLookupText) and
isGitLabIssueUrl to accept /-/work_items/<iid>, mapping it to an issue
work item with the same project-path + iid extraction.

* fix(gitlab): thread connectionId into getGlabKnownHosts call sites

Follow the existing connectionId-threading pattern: pass the repo's
connectionId into every getGlabKnownHosts() call (client.ts,
work-item-details.ts, orca-runtime.ts) so the per-connection known-hosts
cache is keyed correctly and self-hosted hosts are discovered against
the right glab context.

* docs(gitlab): use generic example hosts in comments

* fix(gitlab): pass self-hosted host:port via GITLAB_HOST (glab --hostname rejects ports)

* polish: satisfy oxlint curly + oxfmt on merged gitlab port-recognition code

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Ptah-CT <auctor@xinfty.space>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-03 16:24:01 -07:00

504 lines
17 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
glabExecFileAsyncMock: vi.fn(),
sshExecMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
glabExecFileAsync: glabExecFileAsyncMock
}))
import {
_getProjectRefCacheSize,
_resetKnownHostsCache,
_resetProjectRefCache,
classifyGlabError,
classifyListIssuesError,
getIssueProjectRef,
getGlabKnownHosts,
getProjectRef,
getProjectRefForRemote,
parseGlabApiResponse,
parseGlabAuthStatusHosts,
resolveIssueSource
} from './gl-utils'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
describe('gitlab project ref resolution', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
sshExecMock.mockReset()
unregisterSshGitProvider('conn-1')
_resetProjectRefCache()
})
afterEach(() => {
unregisterSshGitProvider('conn-1')
})
it('keeps getProjectRef origin-based', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:fork/orca.git\n'
})
await expect(getProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'fork/orca'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo'
})
})
it('prefers upstream for issue project ref resolution', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:stablyai/orca.git\n'
})
await expect(getIssueProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'stablyai/orca'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], {
cwd: '/repo'
})
})
it('falls back to origin when upstream is missing or non-GitLab', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@example.com:stablyai/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:fork/orca.git\n' })
await expect(getIssueProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'fork/orca'
})
})
it('does not mix origin and upstream cache entries for the same repo path', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:fork/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:stablyai/orca.git\n' })
await expect(getProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'fork/orca'
})
await expect(getIssueProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'stablyai/orca'
})
})
it('keeps local host and local WSL project-ref cache entries separate for the same path', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:host/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:wsl/orca.git\n' })
await expect(getProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'host/orca'
})
await expect(getProjectRef('/repo', undefined, null, { wslDistro: 'Ubuntu' })).resolves.toEqual(
{
host: 'gitlab.com',
path: 'wsl/orca'
}
)
await expect(getProjectRef('/repo', undefined, null, { wslDistro: 'Ubuntu' })).resolves.toEqual(
{
host: 'gitlab.com',
path: 'wsl/orca'
}
)
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], {
cwd: '/repo'
})
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], {
cwd: '/repo',
wslDistro: 'Ubuntu'
})
})
it('coalesces concurrent missing remote probes for the same repo and remote', async () => {
gitExecFileAsyncMock.mockImplementation(async () => {
await Promise.resolve()
throw new Error("error: No such remote 'upstream'")
})
await expect(
Promise.all([
getProjectRefForRemote('/repo', 'upstream'),
getProjectRefForRemote('/repo', 'upstream'),
getProjectRefForRemote('/repo', 'upstream'),
getProjectRefForRemote('/repo', 'upstream')
])
).resolves.toEqual([null, null, null, null])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], {
cwd: '/repo'
})
await expect(getProjectRefForRemote('/repo', 'upstream')).resolves.toBeNull()
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('resolves project refs through the SSH git provider for connected repos', async () => {
sshExecMock.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' })
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
host: 'gitlab.com',
path: 'remote/orca'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('bounds cached project refs for distinct repo paths', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'git@gitlab.com:stablyai/orca.git\n',
stderr: ''
})
for (let i = 0; i < 513; i += 1) {
await getProjectRef(`/repo-${i}`)
}
expect(_getProjectRefCacheSize()).toBe(512)
})
it('does not cache a missing SSH provider as a permanent null project ref', async () => {
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull()
sshExecMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:remote/orca.git\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
host: 'gitlab.com',
path: 'remote/orca'
})
})
it('does not cache transient SSH exec failures as permanent null project refs', async () => {
sshExecMock
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' })
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull()
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
host: 'gitlab.com',
path: 'remote/orca'
})
})
})
describe('resolveIssueSource', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
_resetProjectRefCache()
})
it("'auto' + upstream exists → upstream, fellBack=false", async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:stablyai/orca.git\n'
})
await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({
source: { host: 'gitlab.com', path: 'stablyai/orca' },
fellBack: false
})
})
it("'auto' + no upstream → origin, fellBack=false", async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@example.com:stablyai/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:solo/orca.git\n' })
await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({
source: { host: 'gitlab.com', path: 'solo/orca' },
fellBack: false
})
})
it("'upstream' + no upstream remote → origin, fellBack=true", async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('fatal: No such remote'))
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:solo/orca.git\n' })
await expect(resolveIssueSource('/repo', 'upstream')).resolves.toEqual({
source: { host: 'gitlab.com', path: 'solo/orca' },
fellBack: true
})
})
it("'origin' + upstream exists → origin (ignores upstream), fellBack=false", async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:fork/orca.git\n'
})
await expect(resolveIssueSource('/repo', 'origin')).resolves.toEqual({
source: { host: 'gitlab.com', path: 'fork/orca' },
fellBack: false
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo'
})
})
it('undefined preference is treated identically to auto', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:stablyai/orca.git\n'
})
await expect(resolveIssueSource('/repo', undefined)).resolves.toEqual({
source: { host: 'gitlab.com', path: 'stablyai/orca' },
fellBack: false
})
})
})
describe('glab error classification', () => {
it('classifies 403/forbidden as permission_denied', () => {
expect(classifyGlabError('HTTP 403 Forbidden').type).toBe('permission_denied')
expect(classifyGlabError('insufficient_scope').type).toBe('permission_denied')
})
it('classifies 404 / project not found as not_found', () => {
expect(classifyGlabError('HTTP 404 Not Found').type).toBe('not_found')
expect(classifyGlabError('Project Not Found').type).toBe('not_found')
})
it('classifies 422 / unprocessable as validation_error', () => {
expect(classifyGlabError('HTTP 422 Unprocessable Entity').type).toBe('validation_error')
})
it('classifies rate-limit signals as rate_limited', () => {
expect(classifyGlabError('HTTP 429 Too Many Requests').type).toBe('rate_limited')
expect(classifyGlabError('rate limit exceeded').type).toBe('rate_limited')
})
it('classifies timeout / dns / network as network_error', () => {
expect(classifyGlabError('connection timeout').type).toBe('network_error')
expect(classifyGlabError('could not resolve host: gitlab.com').type).toBe('network_error')
expect(classifyGlabError('network unreachable').type).toBe('network_error')
})
it('falls back to unknown for unrecognized stderr', () => {
expect(classifyGlabError('something weird happened').type).toBe('unknown')
})
it('rewrites copy for read contexts via classifyListIssuesError', () => {
expect(classifyListIssuesError('HTTP 403').message).toMatch(/permission to read issues/i)
expect(classifyListIssuesError('HTTP 404').message).toBe('Project not found.')
})
})
describe('glab auth status host parsing', () => {
it('extracts hosts from "Logged in to <host>" lines', () => {
const out = `
✓ Logged in to gitlab.com as user1 (oauth2)
✓ Logged in to gitlab.example.com as user2 (token)
`
expect(parseGlabAuthStatusHosts(out).sort()).toEqual(['gitlab.com', 'gitlab.example.com'])
})
it('extracts hosts from header-style lines', () => {
const out = `
gitlab.example.com:
Logged in as user2
`
expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com')
})
it('extracts hosts from bare auth-status section headers', () => {
const out = `
gitlab.com
✓ Logged in to gitlab.com as user1 (/home/user/.config/glab-cli/config.yml)
✓ Token: **************************
gitlab.internal
✓ Logged in as user2
✓ Token: **************************
Self-hosted-git
✓ Logged in as user3
`
expect(parseGlabAuthStatusHosts(out).sort()).toEqual([
'gitlab.com',
'gitlab.internal',
'self-hosted-git'
])
})
it('returns empty list for output with no hosts', () => {
expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([])
})
it('captures a non-default port on "Logged in to" lines', () => {
const out = '✓ Logged in to gitlab.example.com:8080 as user (token)'
expect(parseGlabAuthStatusHosts(out)).toEqual(['gitlab.example.com:8080'])
})
it('captures a non-default port on header-style lines', () => {
const out = `
gitlab.example.com:8080:
✓ Logged in as user
`
expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com:8080')
})
it('keeps two services on the same host distinct by port', () => {
const out = `
✓ Logged in to gitlab.example.com:8443 as user (token)
✓ Logged in to gitlab.example.com:3030 as user (token)
`
expect(parseGlabAuthStatusHosts(out).sort()).toEqual([
'gitlab.example.com:3030',
'gitlab.example.com:8443'
])
})
})
describe('parseGlabApiResponse', () => {
it('splits headers and body at the first blank line (LF)', () => {
const stdout = 'HTTP/2.0 200 OK\nX-Total: 42\nX-Total-Pages: 3\n\n[{"iid":1}]'
const parsed = parseGlabApiResponse(stdout)
expect(parsed.headers).toEqual({ 'x-total': '42', 'x-total-pages': '3' })
expect(parsed.body).toBe('[{"iid":1}]')
})
it('handles CRLF line endings', () => {
const stdout = 'HTTP/2.0 200 OK\r\nX-Total: 7\r\n\r\n[]'
const parsed = parseGlabApiResponse(stdout)
expect(parsed.headers['x-total']).toBe('7')
expect(parsed.body).toBe('[]')
})
it('splits large bodies without full-output separator matching', () => {
const matchSpy = vi.spyOn(String.prototype, 'match')
const body = '[{"iid":1}]'.repeat(10_000)
const parsed = parseGlabApiResponse(`HTTP/2.0 200 OK\r\nX-Total: 7\r\n\r\n${body}`)
expect(parsed.headers['x-total']).toBe('7')
expect(parsed.body).toBe(body)
const usedSeparatorMatch = matchSpy.mock.calls.some(
([pattern]) => pattern instanceof RegExp && pattern.source === '\\r?\\n\\r?\\n'
)
expect(usedSeparatorMatch).toBe(false)
})
it('lowercases header names for stable lookup', () => {
const stdout = 'HTTP/2.0 200 OK\nX-Total: 1\nContent-Type: application/json\n\n{}'
const parsed = parseGlabApiResponse(stdout)
expect(parsed.headers['x-total']).toBe('1')
expect(parsed.headers['content-type']).toBe('application/json')
})
it('returns the full input as body when there is no header separator', () => {
const stdout = '{"iid":1}'
const parsed = parseGlabApiResponse(stdout)
expect(parsed.body).toBe(stdout)
expect(parsed.headers).toEqual({})
})
it('skips the status line in the header block', () => {
const stdout = 'HTTP/2.0 200 OK\nX-Total: 5\n\n[]'
const parsed = parseGlabApiResponse(stdout)
// The status line should not have leaked into headers under any key.
expect(parsed.headers['http/2.0']).toBeUndefined()
expect(parsed.headers['x-total']).toBe('5')
})
})
describe('getGlabKnownHosts', () => {
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
_resetKnownHostsCache()
})
it('returns gitlab.com plus auth-status hosts, deduped', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.com as user\n✓ Logged in to gitlab.example.com as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com'])
})
it('falls back to default when glab auth status fails', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated'))
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
})
it('caches the result across calls', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.com as user\n',
stderr: ''
})
await getGlabKnownHosts()
await getGlabKnownHosts()
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('recognizes a self-hosted host on a non-default port', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com:8080'])
})
it('caches per connection — the local probe does not satisfy a connection probe', async () => {
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.com as user\n', stderr: '' })
.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
// A second probe for the same connection is served from cache.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not permanently cache the failure fallback — a later probe can re-discover hosts', async () => {
glabExecFileAsyncMock
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
// First probe fails → canonical default, NOT cached.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com'])
// Re-probe (e.g. after tunnel comes up) discovers the real host.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
})