import { execFile } from 'child_process' import { promisify } from 'util' import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types' import { getSshGitProvider } from '../providers/ssh-git-dispatch' // Why: legacy generic execFile wrapper — only used by callers that don't need // WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from // the runner instead. export const execFileAsync = promisify(execFile) export { glabExecFileAsync, gitExecFileAsync } // ── Concurrency limiter — max 4 parallel glab processes ───────────── // Why: parallel to gh-utils' limiter. Separate state from the gh limiter // because gh and glab are independent binaries; one provider's spawns // shouldn't throttle the other's. Cap matches gh-utils for consistency. const MAX_CONCURRENT = 4 let running = 0 const queue: (() => void)[] = [] export function acquire(): Promise { if (running < MAX_CONCURRENT) { running++ return Promise.resolve() } return new Promise((resolve) => queue.push(() => { running++ resolve() }) ) } export function release(): void { running-- const next = queue.shift() if (next) { next() } } // ── Error classification ───────────────────────────────────────────── // Why: glab CLI surfaces API errors as unstructured stderr — same shape // as gh. Map known GitLab patterns to typed errors so callers can show // user-friendly messages. export function classifyGlabError(stderr: string): ClassifiedError { const s = stderr.toLowerCase() if (s.includes('http 403') || s.includes('forbidden') || s.includes('insufficient_scope')) { return { type: 'permission_denied', message: "You don't have permission to edit this issue. Check your GitLab token scopes." } } if (s.includes('http 404') || s.includes('project not found')) { return { type: 'not_found', message: 'Issue not found — it may have been deleted.' } } if (s.includes('http 422') || s.includes('unprocessable')) { return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` } } // Why: GitLab returns 429 for rate limit; gh's "rate limit" stderr substring // also fires through the user-mode token bucket. Cover both. if (s.includes('rate limit') || s.includes('http 429')) { return { type: 'rate_limited', message: 'GitLab rate limit hit. Try again in a few minutes.' } } if ( s.includes('timeout') || s.includes('no such host') || s.includes('network') || s.includes('could not resolve host') ) { return { type: 'network_error', message: 'Network error — check your connection.' } } return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` } } // Why: classifyGlabError's copy is phrased for edit/update operations; // listIssues is a read op. Rewrite the message for read contexts while // keeping the typed classification intact for callers/telemetry. export function classifyListIssuesError(stderr: string): ClassifiedError { const c = classifyGlabError(stderr) const trimmed = stderr.trim() // Exhaustive map so newly added error types surface as a TS error here // rather than silently falling through to edit-phrased copy. const readMessages: Record = { permission_denied: "You don't have permission to read issues for this project. Check your GitLab token scopes.", not_found: 'Project not found.', issues_disabled: 'Issues are disabled on this project.', validation_error: `Invalid request — ${trimmed}`, rate_limited: 'GitLab rate limit hit. Try again in a few minutes.', network_error: 'Network error — check your connection.', unknown: `Failed to load issues: ${trimmed}` } return { type: c.type, message: readMessages[c.type] } } // ── Project ref resolution ────────────────────────────────────────── // Why: alias the shared shape so `src/shared/types.ts#GitLabProjectRef` // remains the single source of truth while main-side call sites can use // the short local name `ProjectRef`. export type ProjectRef = GitLabProjectRef const PROJECT_REF_CACHE_MAX_ENTRIES = 512 const projectRefCache = new Map() /** @internal — exposed for tests only */ export function _resetProjectRefCache(): void { projectRefCache.clear() } /** @internal — exposed for tests only */ export function _getProjectRefCacheSize(): number { return projectRefCache.size } function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { projectRefCache.set(cacheKey, value) while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) { const oldestKey = projectRefCache.keys().next().value if (oldestKey === undefined) { return } projectRefCache.delete(oldestKey) } } /** * Hosts always treated as GitLab. Self-hosted instances are added at * runtime via `getGlabKnownHosts()`, which inspects `glab auth status`. */ export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const function normalizeHost(value: string): string { return value.trim().toLowerCase() } function stripGitSuffix(path: string): string { return path.replace(/\.git$/i, '') } function makeProjectRef( host: string, path: string, knownHosts: readonly string[] ): ProjectRef | null { const normalizedHost = normalizeHost(host) if (!knownHosts.map(normalizeHost).includes(normalizedHost)) { return null } const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim() // Reject paths without at least one group segment — `gitlab.com:foo` // alone is not a project reference. if (!normalizedPath.includes('/')) { return null } return { host: normalizedHost, path: normalizedPath } } export function parseGitLabProjectRef( remoteUrl: string, knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS ): ProjectRef | null { const trimmed = remoteUrl.trim() if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) if (scpLike) { return makeProjectRef(scpLike[1], scpLike[2], knownHosts) } } try { const url = new URL(trimmed) if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) { return null } return makeProjectRef(url.hostname, url.pathname, knownHosts) } catch { return null } } export async function getProjectRefForRemote( repoPath: string, remoteName: string, knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS, connectionId?: string | null ): Promise { const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` if (projectRefCache.has(cacheKey)) { return projectRefCache.get(cacheKey)! } try { const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null if (connectionId && !sshGitProvider) { // Why: mobile can attempt GitLab loads before the SSH tunnel is ready. // Caching that transient state would poison later loads after connect. return null } const { stdout } = sshGitProvider ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) : await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: repoPath }) const result = parseGitLabProjectRef(stdout, knownHosts) if (result) { rememberProjectRefCacheEntry(cacheKey, result) return result } } catch { if (connectionId) { // Why: remote SSH failures are often transient tunnel/process errors. // Do not cache them as "not a GitLab repo" for the rest of the session. return null } // ignore — non-GitLab remote or no remote configured } rememberProjectRefCacheEntry(cacheKey, null) return null } export async function getProjectRef( repoPath: string, knownHosts?: readonly string[], connectionId?: string | null ): Promise { return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) } export async function getIssueProjectRef( repoPath: string, knownHosts?: readonly string[], connectionId?: string | null ): Promise { const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId) if (upstream) { return upstream } return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) } export type ResolvedIssueSource = { source: ProjectRef | null /** True when the user preferred `upstream` but the upstream remote is no * longer configured and the resolver fell back to origin. */ fellBack: boolean } /** * Resolve the issue source for a repo honoring the user's per-repo * preference. Mirrors `resolveIssueSource` in gh-utils — the upstream/ * origin/auto semantics are git-remote concepts, not GitHub-specific. */ export async function resolveIssueSource( repoPath: string, preference: IssueSourcePreference | undefined, knownHosts?: readonly string[], connectionId?: string | null ): Promise { if (preference === 'upstream') { const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId) if (upstream) { return { source: upstream, fellBack: false } } const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId) return { source: origin, fellBack: origin !== null } } if (preference === 'origin') { return { source: await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId), fellBack: false } } return { source: await getIssueProjectRef(repoPath, knownHosts, connectionId), fellBack: false } } export function glabRepoExecOptions( repoPath: string, connectionId?: string | null ): { cwd?: string } { return connectionId ? {} : { cwd: repoPath } } export function glabHostnameArgs( projectRef: Pick | null | undefined, connectionId?: string | null ): string[] { // Why: local glab commands can infer host from cwd; SSH-backed calls have // no local cwd, so self-hosted instances need an explicit hostname. return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : [] } // ── Known-hosts discovery via `glab auth status` ──────────────────── // Why: glab supports multiple hosts (gitlab.com plus self-hosted). The // authoritative list of "what counts as GitLab" from the user's POV is // "what hosts have I authenticated with". Parse hostnames out of // `glab auth status` output and cache the result process-wide. let knownHostsCache: readonly string[] | null = null /** @internal — exposed for tests only */ export function _resetKnownHostsCache(): void { knownHostsCache = null } export async function getGlabKnownHosts(): Promise { if (knownHostsCache) { return knownHostsCache } try { const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) // Why: glab writes auth status to stderr in some versions, stdout in // others. Concatenate so the parser sees both. const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) // Always include gitlab.com so a fresh-install user with no auth // still recognizes the canonical host. const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) knownHostsCache = merged return merged } catch { // Auth check failed (glab not installed, no auth, etc.) — fall back // to the canonical default. The caller will hit the auth error on // the first real request anyway. knownHostsCache = [...DEFAULT_GITLAB_HOSTS] return knownHostsCache } } // ── Paginated `glab api -i` helper ────────────────────────────────── // Why: GitLab returns total counts via response headers (X-Total, // X-Total-Pages) on paginated REST endpoints. `glab api` discards // headers by default; passing `-i` includes the raw HTTP response // before the JSON body. Parse out the headers + body so callers can // surface "Page X of Y" UIs without hand-rolling a second count call. export type GlabApiResponse = { body: string headers: Record } export async function glabApiWithHeaders( args: string[], options?: { cwd?: string } ): Promise { const { stdout } = await glabExecFileAsync(['api', '-i', ...args], options) return parseGlabApiResponse(stdout) } /** @internal — exported for tests. */ export function parseGlabApiResponse(stdout: string): GlabApiResponse { // Why: response is `HTTP/x.y status\nHeader: val\n…\n\n`. // Match the first blank line (CRLF or LF) as the boundary. const sepMatch = stdout.match(/\r?\n\r?\n/) if (!sepMatch || sepMatch.index === undefined) { return { body: stdout, headers: {} } } const headerBlock = stdout.slice(0, sepMatch.index) const body = stdout.slice(sepMatch.index + sepMatch[0].length) const headers: Record = {} // Skip the status line (HTTP/x.y …) and parse the rest as key: value. const lines = headerBlock.split(/\r?\n/) for (const line of lines) { const m = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/) if (m) { headers[m[1].toLowerCase()] = m[2].trim() } } return { body, headers } } // Why: glab auth status output is human-formatted and varies across versions. // Two patterns observed in the wild: // 1) "✓ Logged in to gitlab.com as " // 2) "gitlab.example.com:" header followed by indented status lines // Match both, dedupe, lowercase. Best-effort — anything that looks like a // hostname. export function parseGlabAuthStatusHosts(output: string): string[] { const hosts = new Set() for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) { hosts.add(m[1].toLowerCase()) } for (const line of output.split('\n')) { const m = line.match(/^([a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}):\s*$/) if (m) { hosts.add(m[1].toLowerCase()) } } return Array.from(hosts) }