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>
This commit is contained in:
Ptah
2026-07-03 16:24:01 -07:00
committed by GitHub
co-authored by Orca Ptah-CT Neil
parent 25896f2cdb
commit a10e1d7584
16 changed files with 415 additions and 116 deletions
+47 -3
View File
@@ -12,10 +12,33 @@ export function normalizeGitLabHost(value: string): string {
return value.trim().toLowerCase()
}
// Why: host recognition is port-aware so two services on the same hostname
// but different ports (e.g. a GitLab on :8080 and a Gitea on :3030) are not
// conflated. The hostname (port-less) part is kept for legacy known-host
// entries that were recorded without a port.
function hostnameOf(host: string): string {
// `host` may be `name` or `name:port`. Strip a trailing `:digits` port.
return host.replace(/:\d+$/, '')
}
function stripGitSuffix(path: string): string {
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
}
// Why: the GitLab host identity is the web/API endpoint, which is what `glab
// --hostname` and the known-hosts list speak in terms of. For http(s)
// remotes the URL port IS that endpoint port (e.g. self-hosted on :8080),
// so it must be kept. For ssh/git remotes the port is a transport port
// (e.g. ssh on :2222) that does not identify the GitLab instance, so it is
// dropped and only the hostname is used.
function hostIdentityFromUrl(url: URL): string {
const protocol = url.protocol.toLowerCase()
if (protocol === 'http:' || protocol === 'https:') {
return url.host
}
return url.hostname
}
function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null {
const normalizedHost = normalizeGitLabHost(host)
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
@@ -27,6 +50,27 @@ function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef |
return { host: normalizedHost, path: normalizedPath }
}
/**
* Does `urlHost` (which may include a `:port`) match a known-host entry?
* - An exact match (including any port) always counts.
* - A known entry WITHOUT a port also matches a URL host on the same
* hostname regardless of the URL's port — this preserves recognition for
* legacy `gitlab.com` / bare-hostname known entries.
* - A known entry WITH a port only matches a URL host with the exact same
* port, so `gitlab.example.com:8443` does not accept a
* `gitea.example.com:3000` (or same-host different-port) remote.
*/
function knownHostMatches(urlHost: string, knownHost: string): boolean {
if (urlHost === knownHost) {
return true
}
if (hostnameOf(knownHost) === knownHost) {
// Known entry has no port — match on hostname alone.
return hostnameOf(urlHost) === knownHost
}
return false
}
function makeProjectRef(
host: string,
path: string,
@@ -34,7 +78,7 @@ function makeProjectRef(
): ProjectRef | null {
const normalizedHost = normalizeGitLabHost(host)
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
if (!normalizedKnownHosts.includes(normalizedHost)) {
if (!normalizedKnownHosts.some((knownHost) => knownHostMatches(normalizedHost, knownHost))) {
return null
}
return makeProjectRefForTrustedHost(normalizedHost, path)
@@ -54,7 +98,7 @@ export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef |
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return makeProjectRefForTrustedHost(url.hostname, url.pathname)
return makeProjectRefForTrustedHost(hostIdentityFromUrl(url), url.pathname)
} catch {
return null
}
@@ -77,7 +121,7 @@ export function parseGitLabProjectRef(
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return makeProjectRef(url.hostname, url.pathname, knownHosts)
return makeProjectRef(hostIdentityFromUrl(url), url.pathname, knownHosts)
} catch {
return null
}