/* eslint-disable max-lines -- Why: parallel to src/main/github/client.ts — co-locating GitLab MR/issue/work-item operations keeps the concurrency acquire/release pattern obvious across operations. */ import type { ClassifiedError, GitLabAssignableUser, GitLabAuthDiagnostic, GitLabDiscussionResolveResult, GitLabJobTraceResult, GitLabPagedResult, GitLabPipelineJob, GitLabRateLimitSnapshot, GitLabMRInlineCommentInput, GitLabMRReviewersUpdateResult, GitLabRetryJobResult, GitLabTodo, GitLabViewer, GitLabWorkItem, GetGitLabRateLimitResult, IssueSourcePreference, ListMergeRequestsResult, MRComment, MRInfo, MRListState } from '../../shared/types' import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers' import { acquire, classifyGlabError, classifyListIssuesError, getGlabKnownHosts, getProjectRef, getProjectRefForRemote, glabHostnameArgs, glabRepoExecOptions, glabApiWithHeaders, glabExecFileAsync, parseGlabAuthStatusHosts, release, resolveIssueSource, type LocalGitExecOptions, type ProjectRef } from './gl-utils' import type { IssueListState } from './issues' import { hasHostedReviewLocalGitOptions, getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' // Why: glab REST API addresses projects by URL-encoded path. Centralized // so call sites don't forget the slash escapes for nested groups. function encodedProject(projectPath: string): string { return encodeURIComponent(projectPath) } const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 const gitLabRateLimitCache = new Map() type HostedReviewLocalGitOptions = ReturnType function hostedReviewLocalGitOptionArgs( options: HostedReviewExecutionOptions = {} ): [] | [HostedReviewLocalGitOptions] { return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] } /** * Get the authenticated GitLab viewer. Mirrors getAuthenticatedViewer * from the GitHub client — returns null when glab is unavailable, the * user is unauthenticated, or the lookup fails. */ export async function getAuthenticatedViewer(): Promise { await acquire() try { const { stdout } = await glabExecFileAsync(['api', 'user']) const viewer = JSON.parse(stdout) as { username?: string; email?: string | null } if (!viewer.username?.trim()) { return null } return { username: viewer.username.trim(), email: viewer.email?.trim() || null } } catch { return null } finally { release() } } export async function diagnoseAuth(): Promise { const envTokenInProcess = process.env.GITLAB_TOKEN ? 'GITLAB_TOKEN' : process.env.GLAB_TOKEN ? 'GLAB_TOKEN' : null try { // Why: a host-global diagnostic must not wake an unrelated default WSL distro. const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { allowDefaultWslFallback: false }) const output = `${stdout}\n${stderr}` const hosts = parseGlabAuthStatusHosts(output) return { glabAvailable: true, authenticated: /logged in|authenticated|token/i.test(output) && !/not logged in/i.test(output), hosts, activeHost: hosts[0] ?? null, envTokenInProcess, error: null } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { glabAvailable: !/ENOENT|not found|spawn/i.test(message), authenticated: false, hosts: [], activeHost: null, envTokenInProcess, error: message } } } function parseRateLimitHeader( headers: Record, keys: readonly string[] ): number | null { for (const key of keys) { const parsed = Number.parseInt(headers[key], 10) if (Number.isFinite(parsed)) { return parsed } } return null } function parseRateLimitResetAt(headers: Record): number | null { const numeric = parseRateLimitHeader(headers, ['ratelimit-reset', 'x-ratelimit-reset']) if (numeric !== null) { return numeric } const resetTime = headers['ratelimit-resettime'] ?? headers['x-ratelimit-resettime'] if (!resetTime) { return null } const millis = Date.parse(resetTime) return Number.isFinite(millis) ? Math.floor(millis / 1000) : null } function parseGitLabRateLimitSnapshot( headers: Record, host: string | null ): GitLabRateLimitSnapshot { const limit = parseRateLimitHeader(headers, ['ratelimit-limit', 'x-ratelimit-limit']) const remaining = parseRateLimitHeader(headers, ['ratelimit-remaining', 'x-ratelimit-remaining']) const resetAt = parseRateLimitResetAt(headers) return { host, fetchedAt: Date.now(), rest: limit === null && remaining === null && resetAt === null ? null : { limit: limit ?? 0, remaining: remaining ?? 0, resetAt } } } /** @internal — test-only */ export function _resetGitLabRateLimitCache(): void { gitLabRateLimitCache.clear() } /** @internal — test-only */ export function _getGitLabRateLimitCacheSize(): number { return gitLabRateLimitCache.size } function pruneGitLabRateLimitCache(now = Date.now()): void { for (const [cacheKey, snapshot] of gitLabRateLimitCache) { if (now - snapshot.fetchedAt >= GITLAB_RATE_LIMIT_CACHE_TTL_MS) { gitLabRateLimitCache.delete(cacheKey) } } while (gitLabRateLimitCache.size > GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES) { const oldestKey = gitLabRateLimitCache.keys().next().value if (oldestKey === undefined) { break } gitLabRateLimitCache.delete(oldestKey) } } function rememberGitLabRateLimitSnapshot( cacheKey: string, snapshot: GitLabRateLimitSnapshot ): void { pruneGitLabRateLimitCache() // Why: self-managed GitLab hostnames come from repo config; keep this // process cache bounded even across many transient hosts. gitLabRateLimitCache.delete(cacheKey) gitLabRateLimitCache.set(cacheKey, snapshot) pruneGitLabRateLimitCache() } export async function getRateLimit(options?: { force?: boolean host?: string | null }): Promise { const host = options?.host?.trim() || null const cacheKey = host ?? 'default' pruneGitLabRateLimitCache() const cached = gitLabRateLimitCache.get(cacheKey) if (!options?.force && cached && Date.now() - cached.fetchedAt < GITLAB_RATE_LIMIT_CACHE_TTL_MS) { return { ok: true, snapshot: cached } } await acquire() try { // Why: GitLab.com and self-managed GitLab instances expose REST budget // headers inconsistently. Query a cheap authenticated endpoint and report // the headers when present; a null bucket means this host omitted them. const args = host ? ['--hostname', host, 'user'] : ['user'] const { headers } = await glabApiWithHeaders(args) const snapshot = parseGitLabRateLimitSnapshot(headers, host) rememberGitLabRateLimitSnapshot(cacheKey, snapshot) return { ok: true, snapshot } } catch (err) { const message = err instanceof Error ? err.message : String(err) return { ok: false, error: message } } finally { release() } } /** * Resolve a project's full GitLab project ref (host + path). Mirrors * github/getRepoSlug. Returns null for non-GitLab remotes. */ export async function getProjectSlug( repoPath: string, connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { const localGitArgs = hostedReviewLocalGitOptionArgs(options) const knownHosts = await getGlabKnownHosts(connectionId, localGitArgs[0]) return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) } /** * Fetch a single merge request with the pipeline status rolled up. * Returns null when the MR doesn't exist or glab fails — callers * decide whether to surface "not found" UI. */ export async function getMergeRequest( repoPath: string, iid: number, connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { const localGitArgs = hostedReviewLocalGitOptionArgs(options) const localGitOptions = localGitArgs[0] ?? {} const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) await acquire() try { const args = projectRef ? [ 'api', ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` ] : ['mr', 'view', String(iid), '--output', 'json'] const { stdout } = await glabExecFileAsync( args, glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as Parameters[0] & { head_pipeline?: { status?: string } | null pipeline?: { status?: string } | null } // Why: GitLab's MR detail surfaces the head pipeline directly. // Older instances expose `pipeline` instead of `head_pipeline` — try // both. If neither is set the rollup falls back to neutral. const pipelineStatus = derivePipelineStatus(data.head_pipeline ?? data.pipeline ?? null) return mapMRInfo(data, pipelineStatus) } catch { return null } finally { release() } } /** * Find the merge request whose source branch matches the given branch * name. Mirrors github/getPRForBranch — returns the most recently * updated MR for the branch, or null when none exists. The branch is the * local checkout's current ref (Orca strips refs/heads/ prefix upstream * so we don't need to here). */ export async function getMergeRequestForBranch( repoPath: string, branch: string, linkedMRIid?: number | null, connectionId?: string | null, options: HostedReviewExecutionOptions = {}, // Why: the existing-review lookup behind Create must distinguish a real glab // transport/auth failure from an accepted "no MR". When true, a failed lookup // throws instead of collapsing to null so callers never report false not_found. throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedMRIid == null) { return null } const localGitArgs = hostedReviewLocalGitOptionArgs(options) const localGitOptions = localGitArgs[0] ?? {} const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) if (!projectRef) { return null } await acquire() try { if (branchName) { const { stdout } = await glabExecFileAsync( [ 'api', ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as (Parameters[0] & { head_pipeline?: { status?: string } | null pipeline?: { status?: string } | null })[] if (Array.isArray(data) && data.length > 0) { const raw = data[0] // Why: older GitLab list payloads expose `pipeline` instead of // `head_pipeline`, matching the detail endpoint compatibility path. const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) return mapMRInfo(raw, pipelineStatus) } } if (typeof linkedMRIid !== 'number') { return null } // Why: create-from-MR worktrees may use a fresh local branch name rather // than the MR source branch. Fall back to the durable linked iid so the // core review status still follows the workspace. const { stdout } = await glabExecFileAsync( [ 'api', ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const raw = JSON.parse(stdout) as Parameters[0] & { head_pipeline?: { status?: string } | null pipeline?: { status?: string } | null } const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) return mapMRInfo(raw, pipelineStatus) } catch (error) { if (throwOnFailure) { throw error } return null } finally { release() } } /** * Existing-review lookup that surfaces glab transport/auth failures instead of * collapsing them to null, so a failed lookup becomes * `reviewLookupOutcome: 'unavailable'` rather than a false "No merge request found". */ export function getMergeRequestForBranchOrThrow( repoPath: string, branch: string, linkedMRIid?: number | null, connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { return getMergeRequestForBranch(repoPath, branch, linkedMRIid, connectionId, options, true) } function mrListStateFlags(state: MRListState): string[] { switch (state) { case 'opened': return [] case 'merged': return ['--merged'] case 'closed': return ['--closed'] case 'all': return ['--all'] } } /** * List merge requests for a project. Uses glab CLI pagination because * it handles self-hosted auth and project selection consistently. */ export async function listMergeRequests( repoPath: string, state: MRListState = 'opened', page = 1, perPage = 20, preference?: IssueSourcePreference, query?: string, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) // Why: MRs sit on `origin` in the fork model (the user's fork is where // they push branches and submit MRs). Mirror github's `getOwnerRepo` // call site by going through the upstream/origin preference resolver // so cross-fork workflows reuse the same plumbing. const { source: projectRef } = await resolveIssueSource( repoPath, preference, knownHosts, connectionId, localGitOptions ) if (!projectRef) { if (connectionId) { // Why: SSH-backed repos have no local cwd for glab to infer from. // Running cwd-less could resolve an unrelated local project instead. return { items: [], page, perPage, totalCount: 0, totalPages: 0, error: { type: 'not_found', message: 'No GitLab project found for this repository.' } } } // Why: fallback — let glab infer project from cwd, same as listIssues. // Used when the repo's remote host is not in getGlabKnownHosts(connectionId) // (e.g. a fresh self-hosted instance), but glab itself can still // resolve it from the local git config. const stateFlag = mrListStateFlags(state) // Why: the cwd-inferred fallback must honor the same search the API path // does, otherwise typing a query against a self-hosted / unresolved-projectRef // repo silently returns the unfiltered list (the original #6263 symptom). const searchFlag = query?.trim() ? ['--search', query.trim()] : [] await acquire() try { const { stdout } = await glabExecFileAsync( [ 'mr', 'list', '--output', 'json', '--per-page', String(perPage), '--page', String(page), '--order', 'updated_at', '--sort', 'desc', ...stateFlag, ...searchFlag ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as Parameters[0][] return { items: data.map((d) => mapMRToWorkItem(d, 'unknown')), page, perPage, // Why: the CLI doesn't return x-total headers, so totals are // approximate. For the Tasks UI this is acceptable. totalCount: data.length, totalPages: data.length < perPage ? page : page + 1 } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) return { items: [], page, perPage, totalCount: 0, totalPages: 0, error: classifyListIssuesError(stderr) } } finally { release() } } // Why: 'all' is exposed as the picker filter but GitLab's API expects // no state param to mean "any state". Drop the param when 'all'. const stateParam = state === 'all' ? '' : `&state=${state}` const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' const path = `projects/${encodedProject(projectRef.path)}/merge_requests?` + `page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}` const repoId = projectRef.path await acquire() try { const { body, headers } = await glabApiWithHeaders( [...glabHostnameArgs(projectRef, connectionId), path], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(body) as Parameters[0][] return { items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)), page, perPage, totalCount: parseHeaderInt(headers['x-total'], 0), // Why: when 'all' state is requested or the per_page is large, // GitLab may not include x-total-pages; fall back to ceil(total/perPage). totalPages: parseHeaderInt(headers['x-total-pages'], 0) || Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) return { items: [], page, perPage, totalCount: 0, totalPages: 0, error: classifyListIssuesError(stderr) } } finally { release() } } function parseHeaderInt(value: string | undefined, fallback: number): number { if (!value) { return fallback } const parsed = Number.parseInt(value, 10) return Number.isFinite(parsed) ? parsed : fallback } /** * Fetch a work item (MR or issue) given an explicit project ref + * iid + type. Mirrors github/getWorkItemByOwnerRepo — used by the * paste-URL flow in the picker where the URL determines the project * directly rather than going through the local repo's remotes. */ export async function getWorkItemByProjectRef( repoPath: string, projectRef: ProjectRef, iid: number, type: 'issue' | 'mr', connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { await acquire() try { const resource = type === 'mr' ? 'merge_requests' : 'issues' const { stdout } = await glabExecFileAsync( [ 'api', // Why: pasted GitLab URLs carry an explicit host; preserve it even for // local/runtime-local repos so cwd remotes cannot redirect the lookup. ...(projectRef.host ? ['--hostname', projectRef.host] : []), `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) if (type === 'mr') { return mapMRToWorkItem(data, projectRef.path, projectRef) } return mapIssueToWorkItem(data, projectRef.path, projectRef) } catch { return null } finally { release() } } function mrStateToIssueState(state: MRListState): IssueListState | null { // Why: GitLab issues don't have a 'merged' state. When the user is // filtering MRs to merged, return null so listWorkItems can skip the // issues fetch entirely instead of mis-mapping to opened/closed. switch (state) { case 'opened': return 'opened' case 'closed': return 'closed' case 'all': return 'all' case 'merged': return null } } export async function listWorkItems( repoPath: string, state: MRListState = 'opened', page = 1, perPage = 20, preference?: IssueSourcePreference, query?: string, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise> { const issueState = mrStateToIssueState(state) const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, knownHosts, connectionId, localGitOptions ) if (!projectRef) { return { items: [], page, perPage, totalCount: 0, totalPages: 0, error: { type: 'not_found', message: 'No GitLab project found for this repository.' } } } // Why: fan out the two read calls so the response time is the slower // of the two, not their sum. Errors classify per-side; an MR-side // failure with a successful issues fetch still surfaces issues with // an error envelope. // // Why we don't go through `listIssues` here: that function returns // IssueInfo, which deliberately strips the raw glab fields (notably // `updated_at`). The combined sort needs updatedAt, so we read the // raw issues API directly and run mapIssueToWorkItem against the // raw payload instead. const [mrs, issues] = await Promise.all([ listMergeRequests( repoPath, state, page, perPage, preference, query, connectionId, localGitOptions ), issueState === null ? Promise.resolve({ items: [] as GitLabWorkItem[], error: undefined as ClassifiedError | undefined }) : fetchIssuesAsWorkItems( repoPath, projectRef, issueState, page, perPage, query, connectionId, localGitOptions ) ]) const merged = [...mrs.items, ...issues.items].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') ) // Why: combine error envelopes — the renderer's banner cares about // any failed fetch, not which one. MR-side error wins because it's // strictly more informative than an issues-side error in most // permission scenarios (issues can be disabled per project). const error: ClassifiedError | undefined = mrs.error ?? issues.error return { items: merged, page, perPage, // Why: approximate totals — an exact combined-pagination total would // require a server-side ordering primitive across two distinct // resources, which the GitLab API doesn't offer. MR total is the // right direction; the UI's "Page X of Y" reads as a hint, not a // strict count. totalCount: mrs.totalCount, totalPages: mrs.totalPages, ...(error ? { error } : {}) } } export async function fetchIssuesAsWorkItems( repoPath: string, projectRef: ProjectRef, state: IssueListState, page: number, perPage: number, query?: string, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> { await acquire() try { const stateParam = state === 'all' ? '' : `&state=${state}` const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' const { stdout } = await glabExecFileAsync( [ 'api', ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/issues?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as Parameters[0][] return { items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)), error: undefined } } catch (err) { return { items: [], error: classifyListIssuesError(err instanceof Error ? err.message : String(err)) } } finally { release() } } /** * List the authenticated user's GitLab todos (gitlab.com/dashboard/todos). * Cross-project — `glab api todos` is user-scoped so the cwd doesn't * affect the result; callers may pass any registered repo path so the * IPC handler's path-validation guard has something to check. * * Why: GitLab's todos surface is the closest GitLab-native analogue of * GitHub's notifications/inbox. Surfacing it in Orca lets users start * work directly from a mention/assignment without going to gitlab.com * first. */ export async function listTodos( repoPath: string, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { const projectRef = await getProjectRef( repoPath, await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) if (connectionId && !projectRef) { return [] } await acquire() try { // Why: per_page=50 keeps this user-scoped cross-project view cheap. The UI // shows the highest-priority todos first, so avoid walking every pending // todo page from large GitLab accounts. const { stdout } = await glabExecFileAsync( [ 'api', ...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []), 'todos?state=pending&per_page=50' ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) type RESTTodo = { id?: number action_name?: string target_type?: string target?: { iid?: number title?: string web_url?: string } | null target_url?: string author?: { username?: string | null; avatar_url?: string | null } | null project?: { path_with_namespace?: string } | null updated_at?: string state?: string } const data = JSON.parse(stdout) as RESTTodo[] return data.map((t) => ({ id: t.id ?? 0, actionName: t.action_name ?? '', targetType: t.target_type ?? '', targetIid: typeof t.target?.iid === 'number' ? t.target.iid : null, targetTitle: t.target?.title ?? '', targetUrl: t.target_url ?? t.target?.web_url ?? '', projectPath: t.project?.path_with_namespace ?? '', authorUsername: t.author?.username ?? '', authorAvatarUrl: t.author?.avatar_url ?? '', updatedAt: t.updated_at ?? '', state: t.state === 'done' ? 'done' : 'pending' })) } catch { // Why: silent empty-list on auth/network failures matches the rest // of the read-side surface (`listLabels`, `listAssignableUsers`). // The caller's banner / loading-state UI signals connectivity issues. return [] } finally { release() } } // ── MR mutations ────────────────────────────────────────────────── // Why: mirror the GitHub-side actions (mergePR, updatePRTitle, close // via gh issue close, etc.) for the GitLab dialog footer. All take a // repoPath + iid and resolve the project ref via the existing helper. async function withProjectRef( repoPath: string, preference: IssueSourcePreference | undefined, connectionId: string | null | undefined, explicitProjectRef: ProjectRef | null | undefined, fn: (projectRef: ProjectRef, repoFlag: string) => Promise, fallback: T, localGitOptions: LocalGitExecOptions = {} ): Promise { const projectRef = explicitProjectRef ?? ( await resolveIssueSource( repoPath, preference, await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) ).source if (!projectRef) { return fallback } return fn(projectRef, projectRef.path) } export async function closeMR( repoPath: string, iid: number, preference?: IssueSourcePreference, connectionId?: string | null, projectRef?: ProjectRef | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, preference, connectionId, projectRef, async (projectRef, repoFlag) => { await acquire() try { await glabExecFileAsync( [ 'mr', 'close', String(iid), '-R', repoFlag, ...glabHostnameArgs(projectRef, connectionId) ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : String(err) // Why: glab returns a non-zero exit when the MR is already // closed — treat that as success since the desired state is // reached. if (msg.toLowerCase().includes('already')) { return { ok: true } } return { ok: false, error: msg } } finally { release() } }, { ok: false, error: 'Could not resolve GitLab project for this repository' }, localGitOptions ) } export async function reopenMR( repoPath: string, iid: number, preference?: IssueSourcePreference, connectionId?: string | null, projectRef?: ProjectRef | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, preference, connectionId, projectRef, async (projectRef, repoFlag) => { await acquire() try { await glabExecFileAsync( [ 'mr', 'reopen', String(iid), '-R', repoFlag, ...glabHostnameArgs(projectRef, connectionId) ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : String(err) if (msg.toLowerCase().includes('already')) { return { ok: true } } return { ok: false, error: msg } } finally { release() } }, { ok: false, error: 'Could not resolve GitLab project for this repository' }, localGitOptions ) } export async function mergeMR( repoPath: string, iid: number, method: 'merge' | 'squash' | 'rebase' = 'merge', preference?: IssueSourcePreference, connectionId?: string | null, projectRef?: ProjectRef | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, preference, connectionId, projectRef, async (projectRef, repoFlag) => { await acquire() try { // Why: glab mr merge accepts --squash and --rebase flags; // omitting both does a regular merge commit. Map our union // to the right glab flag. const methodFlag = method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : [] await glabExecFileAsync( [ 'mr', 'merge', String(iid), '-R', repoFlag, '--yes', ...methodFlag, ...glabHostnameArgs(projectRef, connectionId) ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true } } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) } } finally { release() } }, { ok: false, error: 'Could not resolve GitLab project for this repository' }, localGitOptions ) } export async function addMRComment( repoPath: string, iid: number, body: string, preference?: IssueSourcePreference, connectionId?: string | null, projectRef?: ProjectRef | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( repoPath, preference, connectionId, projectRef, async (projectRef) => { await acquire() try { const { stdout } = await glabExecFileAsync( [ 'api', ...glabHostnameArgs(projectRef, connectionId), '-X', 'POST', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`, '-f', `body=${body}` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as { id?: number author?: { username?: string; avatar_url?: string; state?: string } | null body?: string created_at?: string } return { ok: true, comment: { id: data.id ?? Date.now(), author: data.author?.username ?? 'You', authorAvatarUrl: data.author?.avatar_url ?? '', body: data.body ?? body, createdAt: data.created_at ?? new Date().toISOString(), url: '', isBot: data.author?.state === 'bot' } } } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) } } finally { release() } }, { ok: false, error: 'Could not resolve GitLab project for this repository' }, localGitOptions ) } export async function addMRInlineComment( repoPath: string, iid: number, input: GitLabMRInlineCommentInput, preference?: IssueSourcePreference, connectionId?: string | null, projectRef?: ProjectRef | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( repoPath, preference, connectionId, projectRef, async (projectRef) => { const body = input.body.trim() if (!body) { return { ok: false, error: 'Comment body is required' } } await acquire() try { const oldPath = input.oldPath ?? input.path const { stdout } = await glabExecFileAsync( [ 'api', ...glabHostnameArgs(projectRef, connectionId), '-X', 'POST', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions`, '-f', `body=${body}`, '-f', 'position[position_type]=text', '-f', `position[base_sha]=${input.baseSha}`, '-f', `position[start_sha]=${input.startSha}`, '-f', `position[head_sha]=${input.headSha}`, '-f', `position[old_path]=${oldPath}`, '-f', `position[new_path]=${input.path}`, '-f', `position[new_line]=${input.line}` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as { id?: string notes?: { id?: number author?: { username?: string; avatar_url?: string; state?: string } | null body?: string created_at?: string position?: { new_path?: string; new_line?: number } | null }[] } const note = data.notes?.[0] return { ok: true, comment: { id: note?.id ?? Date.now(), author: note?.author?.username ?? 'You', authorAvatarUrl: note?.author?.avatar_url ?? '', body: note?.body ?? body, createdAt: note?.created_at ?? new Date().toISOString(), url: '', threadId: data.id, isResolved: false, isBot: note?.author?.state === 'bot', path: note?.position?.new_path ?? input.path, line: note?.position?.new_line ?? input.line } } } catch (err) { const msg = err instanceof Error ? err.message : String(err) return { ok: false, error: classifyGlabError(msg).message } } finally { release() } }, { ok: false, error: 'Could not resolve GitLab project for this repository' }, localGitOptions ) } export async function resolveMRDiscussion( repoPath: string, iid: number, discussionId: string, resolved: boolean, preference?: IssueSourcePreference, connectionId?: string | null, projectRef?: ProjectRef | null, localGitOptions: LocalGitExecOptions = {} ): Promise { return withProjectRef( repoPath, preference, connectionId, projectRef, async (projectRef) => { const trimmedDiscussionId = discussionId.trim() if (!trimmedDiscussionId) { return { ok: false, error: 'Discussion id is required' } } await acquire() try { // Why: GitLab resolves/reopens the whole discussion thread, not a single // note; this mirrors GitHub's thread-level resolve mutation. await glabExecFileAsync( [ 'api', ...glabHostnameArgs(projectRef, connectionId), '-X', 'PUT', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions/${encodeURIComponent(trimmedDiscussionId)}`, '-f', `resolved=${resolved ? 'true' : 'false'}` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : String(err) return { ok: false, error: classifyGlabError(msg).message } } finally { release() } }, { ok: false, error: 'Could not resolve GitLab project for this repository' }, localGitOptions ) } function mapRetriedPipelineJob( data: { id?: number pipeline?: { id?: number | null } | null name?: string stage?: string status?: string web_url?: string duration?: number | null }, fallbackJobId: number ): GitLabPipelineJob { return { id: data.id ?? fallbackJobId, ...(typeof data.pipeline?.id === 'number' ? { pipelineId: data.pipeline.id } : {}), name: data.name ?? '', stage: data.stage ?? '', status: data.status ?? '', webUrl: data.web_url ?? '', duration: typeof data.duration === 'number' ? data.duration : null } } function mapGitLabReviewer(raw: { id?: number username?: string | null name?: string | null avatar_url?: string | null state?: string | null }): GitLabAssignableUser | null { if (!raw.username) { return null } return { ...(typeof raw.id === 'number' ? { id: raw.id } : {}), username: raw.username, name: raw.name ?? null, avatarUrl: raw.avatar_url ?? '', ...(raw.state !== undefined ? { state: raw.state } : {}) } } export async function updateMRReviewers( repoPath: string, iid: number, reviewerIds: number[], preference?: IssueSourcePreference, connectionId?: string | null, projectRef?: ProjectRef | null, localGitOptions: LocalGitExecOptions = {} ): Promise { return withProjectRef( repoPath, preference, connectionId, projectRef, async (projectRef) => { await acquire() try { const fields = reviewerIds.length > 0 ? reviewerIds.flatMap((id) => ['-f', `reviewer_ids[]=${id}`]) : ['-f', 'reviewer_ids='] const { stdout } = await glabExecFileAsync( [ 'api', ...glabHostnameArgs(projectRef, connectionId), '-X', 'PUT', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, ...fields ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const data = JSON.parse(stdout) as { reviewers?: Parameters[0][] } return { ok: true, reviewers: (data.reviewers ?? []) .map(mapGitLabReviewer) .filter((u): u is GitLabAssignableUser => !!u) } } catch (err) { const msg = err instanceof Error ? err.message : String(err) return { ok: false, error: classifyGlabError(msg).message } } finally { release() } }, { ok: false, error: 'Could not resolve GitLab project for this repository' }, localGitOptions ) } export async function getJobTrace( repoPath: string, jobId: number, preference?: IssueSourcePreference, connectionId?: string | null, projectRef?: ProjectRef | null, localGitOptions: LocalGitExecOptions = {} ): Promise { return withProjectRef( repoPath, preference, connectionId, projectRef, async (projectRef) => { await acquire() try { const { stdout } = await glabExecFileAsync( [ 'api', ...glabHostnameArgs(projectRef, connectionId), `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/trace` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true, trace: stdout } } catch (err) { const msg = err instanceof Error ? err.message : String(err) return { ok: false, error: classifyGlabError(msg).message } } finally { release() } }, { ok: false, error: 'Could not resolve GitLab project for this repository' }, localGitOptions ) } export async function retryJob( repoPath: string, jobId: number, preference?: IssueSourcePreference, connectionId?: string | null, projectRef?: ProjectRef | null, localGitOptions: LocalGitExecOptions = {} ): Promise { return withProjectRef( repoPath, preference, connectionId, projectRef, async (projectRef) => { await acquire() try { const { stdout } = await glabExecFileAsync( [ 'api', ...glabHostnameArgs(projectRef, connectionId), '-X', 'POST', `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/retry` ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) const trimmed = stdout.trim() return { ok: true, ...(trimmed ? { job: mapRetriedPipelineJob(JSON.parse(trimmed), jobId) } : {}) } } catch (err) { const msg = err instanceof Error ? err.message : String(err) return { ok: false, error: classifyGlabError(msg).message } } finally { release() } }, { ok: false, error: 'Could not resolve GitLab project for this repository' }, localGitOptions ) } export async function updateMR( repoPath: string, iid: number, updates: { title?: string body?: string addLabels?: string[] removeLabels?: string[] }, preference?: IssueSourcePreference, connectionId?: string | null, projectRef?: ProjectRef | null, localGitOptions: LocalGitExecOptions = {} ): Promise<{ ok: true } | { ok: false; error: string }> { return withProjectRef<{ ok: true } | { ok: false; error: string }>( repoPath, preference, connectionId, projectRef, async (projectRef) => { const fields: string[] = [] const title = updates.title?.trim() if (updates.title !== undefined) { if (!title) { return { ok: false, error: 'Title is required' } } fields.push(`title=${title}`) } if (updates.body !== undefined) { fields.push(`description=${updates.body}`) } const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0) const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0) if (addLabels.length > 0) { fields.push(`add_labels=${addLabels.join(',')}`) } if (removeLabels.length > 0) { fields.push(`remove_labels=${removeLabels.join(',')}`) } if (fields.length === 0) { return { ok: true } } await acquire() try { await glabExecFileAsync( [ 'api', ...glabHostnameArgs(projectRef, connectionId), '-X', 'PUT', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, ...fields.flatMap((field) => ['-f', field]) ], glabRepoExecOptions(repoPath, connectionId, localGitOptions) ) return { ok: true } } catch (err) { const msg = err instanceof Error ? err.message : String(err) return { ok: false, error: classifyGlabError(msg).message } } finally { release() } }, { ok: false, error: 'Could not resolve GitLab project for this repository' }, localGitOptions ) } /** Re-export so callers don't need to know the gl-utils module split. */ export { _resetProjectRefCache } from './gl-utils' export { addIssueComment, createIssue, getIssue, listAssignableUsers, listIssues, listLabels, updateIssue } from './issues' // Why: surface the upstream-aware project-ref helper so non-issue call // sites that need the resolved project (e.g. the paste-URL UI) don't // have to import from gl-utils directly. export { getProjectRefForRemote }