Files
orca/src/main/gitlab/client.ts
Neil 83117f2860 refactor(integrations): split issue-tracker clients under the max-lines budget (#14704)
The GitLab, GitHub, Jira and Linear integration modules, their two IPC
registrars, and the shared GitHub project types each carried a file-level
`eslint-disable max-lines` and ran 351-614 counted lines against a 300-line
budget. AGENTS.md calls for splitting rather than suppressing, and
config/max-lines-baseline.txt is a shrink-only ratchet, so this removes all
eight suppressions and prunes their entries (341 -> 333).

Pure move, no behavior change. Each client is cut along the seam it already
had: per-operation modules for the issue APIs (create / update / comment /
field options), and for Jira the request queue, site credential store,
authenticated request, and site identity. The two IPC registrars keep their own
handlers and delegate the rest to per-domain sub-registrars, so they remain
real entry points rather than re-export shims.

The IPC surface is proved intact rather than assumed: comparing (method,
channel) multisets between HEAD and the split gives 52 registrations across 52
distinct channels on both sides.

Provider-neutrality is preserved -- GitLab and GitHub keep separate, parallel
module layouts rather than being merged behind a shared abstraction.

Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green
(the one remaining failure is a pre-existing load flake in an untouched file,
green when re-run serially), no new runtime import cycles among 744 modules,
and no lint suppression added anywhere.
2026-08-15 18:17:20 -07:00

1379 lines
43 KiB
TypeScript

/* eslint-disable max-lines -- co-locates GitLab MR/issue/work-item operations sharing one acquire/release pattern. */
import type { ClassifiedError } from '../../shared/classified-error'
import type {
GetGitLabRateLimitResult,
GitLabAssignableUser,
GitLabAuthDiagnostic,
GitLabDiscussionResolveResult,
GitLabJobTraceResult,
GitLabMRInlineCommentInput,
GitLabMRReviewersUpdateResult,
GitLabPagedResult,
GitLabPipelineJob,
GitLabRateLimitSnapshot,
GitLabRetryJobResult,
GitLabTodo,
GitLabViewer,
GitLabWorkItem,
ListMergeRequestsResult,
MRComment,
MRInfo,
MRListState
} from '../../shared/gitlab-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers'
import {
acquire,
classifyGlabError,
classifyJobLogError,
classifyListFetchError,
isMissingJobLogError,
getGlabKnownHosts,
getProjectRef,
getProjectRefForRemote,
glabHostnameArgs,
glabRepoExecOptions,
glabApiWithHeaders,
glabExecFileAsync,
parseGlabAuthStatusHosts,
parseGlabJsonList,
release,
resolveIssueSource,
type LocalGitExecOptions,
type ProjectRef
} from './gl-utils'
import { rememberGlabKnownHosts } from './gitlab-known-host-probe'
import type { IssueListState } from './issues'
import {
hasHostedReviewLocalGitOptions,
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch'
// Why: glab REST addresses projects by URL-encoded path; escapes slashes 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<string, GitLabRateLimitSnapshot>()
type HostedReviewLocalGitOptions = ReturnType<typeof getHostedReviewLocalGitOptions>
function hostedReviewLocalGitOptionArgs(
options: HostedReviewExecutionOptions = {}
): [] | [HostedReviewLocalGitOptions] {
return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : []
}
/**
* Get the authenticated GitLab viewer.
* Returns null when glab is unavailable, unauthenticated, or the lookup fails.
*/
export async function getAuthenticatedViewer(): Promise<GitLabViewer | null> {
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<GitLabAuthDiagnostic> {
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)
// Why: refreshing auth must advance the provider cache key past a stale null result.
rememberGlabKnownHosts(hosts)
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<string, string>,
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<string, string>): 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<string, string>,
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 hostnames come from repo config; keep this cache bounded across many transient hosts.
gitLabRateLimitCache.delete(cacheKey)
gitLabRateLimitCache.set(cacheKey, snapshot)
pruneGitLabRateLimitCache()
}
export async function getRateLimit(options?: {
force?: boolean
host?: string | null
}): Promise<GetGitLabRateLimitResult> {
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 exposes REST budget headers inconsistently; 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); null for non-GitLab remotes. */
export async function getProjectSlug(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<ProjectRef | null> {
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const knownHosts = await getGlabKnownHosts(connectionId, localGitArgs[0])
return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs)
}
/**
* Fetch a single merge request with pipeline status rolled up.
* Returns null when the MR doesn't exist or glab fails.
*/
export async function getMergeRequest(
repoPath: string,
iid: number,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<MRInfo | null> {
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<typeof mapMRInfo>[0] & {
head_pipeline?: { status?: string } | null
pipeline?: { status?: string } | null
}
// Why: older GitLab instances expose `pipeline` instead of `head_pipeline`; try both.
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 name.
* Returns the most recently updated MR for the branch, or null when none exists.
*/
export async function getMergeRequestForBranch(
repoPath: string,
branch: string,
linkedMRIid?: number | null,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {},
// Why: when true, a failed lookup throws instead of returning null, so callers never report a false not_found.
throwOnFailure = false
): Promise<MRInfo | null> {
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),
// Why: GitLab does not proactively recompute merge status on list endpoints, so this row
// can sit at `unchecked` forever — and the sidebar merge button gates on MERGEABLE. Ask
// for the async recalculation (best-effort; ignored for non-Developers when
// `restrict_merge_status_recheck` is on) so polling converges instead of stalling.
`projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as (Parameters<typeof mapMRInfo>[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`.
const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null)
const info = mapMRInfo(raw, pipelineStatus)
// Why (#9171): discard a non-open implicit branch match on the repo
// default branch and fall through to the linked-iid fallback below.
const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({
state: info.state,
reviewNumber: info.number,
linkedReviewNumber: linkedMRIid,
branchName,
repoPath,
connectionId,
localGitOptions
})
if (!hideOnDefaultBranch) {
return info
}
}
}
if (typeof linkedMRIid !== 'number') {
return null
}
// Why: create-from-MR worktrees may rename the branch; fall back to the durable linked iid.
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<typeof mapMRInfo>[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()
}
}
/**
* Like getMergeRequestForBranch but throws glab failures instead of returning null, so callers report 'unavailable' not a false "not found".
*/
export function getMergeRequestForBranchOrThrow(
repoPath: string,
branch: string,
linkedMRIid?: number | null,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<MRInfo | null> {
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 via glab CLI, which handles self-hosted auth and project selection. */
export async function listMergeRequests(
repoPath: string,
state: MRListState = 'opened',
page = 1,
perPage = 20,
preference?: IssueSourcePreference,
query?: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ListMergeRequestsResult> {
const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions)
// Why: MRs live on the user's fork (origin); route through the preference resolver so fork workflows share plumbing.
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId,
localGitOptions
)
if (!projectRef) {
if (connectionId) {
// Why: SSH-backed repos have no local cwd; a cwd-less glab could resolve an unrelated project.
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 the project from cwd when the host isn't in getGlabKnownHosts.
const stateFlag = mrListStateFlags(state)
// Why: apply the same search as the API path, else queries are silently ignored on cwd-inferred repos (#6263).
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 = parseGlabJsonList<Parameters<typeof mapMRToWorkItem>[0]>(stdout)
return {
items: data.map((d) => mapMRToWorkItem(d, 'unknown')),
page,
perPage,
// Why: the CLI omits x-total headers, so totals are approximate.
totalCount: data.length,
totalPages: data.length < perPage ? page : page + 1
}
} catch (err) {
return {
items: [],
page,
perPage,
totalCount: 0,
totalPages: 0,
error: classifyListFetchError(err)
}
} finally {
release()
}
}
// Why: GitLab's API uses an absent state param to mean "any"; drop it for '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 = parseGlabJsonList<Parameters<typeof mapMRToWorkItem>[0]>(body)
return {
items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)),
page,
perPage,
totalCount: parseHeaderInt(headers['x-total'], 0),
// Why: GitLab may omit x-total-pages for 'all' or large per_page; 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) {
return {
items: [],
page,
perPage,
totalCount: 0,
totalPages: 0,
error: classifyListFetchError(err)
}
} 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) by explicit project ref + iid + type.
* Used by the paste-URL flow, where the URL determines the project directly.
*/
export async function getWorkItemByProjectRef(
repoPath: string,
projectRef: ProjectRef,
iid: number,
type: 'issue' | 'mr',
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabWorkItem | null> {
await acquire()
try {
const resource = type === 'mr' ? 'merge_requests' : 'issues'
const { stdout } = await glabExecFileAsync(
[
'api',
// Why: preserve the pasted URL's explicit host so cwd remotes can't 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: issues have no 'merged' state; return null so the issues fetch is skipped, not mis-mapped.
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<GitLabPagedResult<GitLabWorkItem>> {
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 both reads so latency is the slower of the two, not the sum.
// Why read the raw issues API (not listIssues): IssueInfo strips updated_at, which the combined sort needs.
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: MR-side error wins — issues can be disabled per project, making its error less informative.
const error: ClassifiedError | undefined = mrs.error ?? issues.error
return {
items: merged,
page,
perPage,
// Why: approximate totals — GitLab can't paginate across MRs+issues as one set, so MR totals stand in.
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 = parseGlabJsonList<Parameters<typeof mapIssueToWorkItem>[0]>(stdout)
return {
items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)),
error: undefined
}
} catch (err) {
return {
items: [],
error: classifyListFetchError(err)
}
} finally {
release()
}
}
/**
* List the authenticated user's GitLab todos (gitlab.com/dashboard/todos).
* User-scoped, so cwd is irrelevant; repoPath only satisfies the IPC handler's path-validation guard.
*/
export async function listTodos(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabTodo[]> {
const projectRef = await getProjectRef(
repoPath,
await getGlabKnownHosts(connectionId, localGitOptions),
connectionId,
localGitOptions
)
if (connectionId && !projectRef) {
return []
}
await acquire()
try {
// Why: per_page=50 keeps this cross-project view cheap; the UI only shows top-priority todos.
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<GitLabTodo>((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 failure matches the read-side surface; caller UI signals connectivity.
return []
} finally {
release()
}
}
// ── MR mutations ──────────────────────────────────────────────────
async function withProjectRef<T>(
repoPath: string,
preference: IssueSourcePreference | undefined,
connectionId: string | null | undefined,
explicitProjectRef: ProjectRef | null | undefined,
fn: (projectRef: ProjectRef, repoFlag: string) => Promise<T>,
fallback: T,
localGitOptions: LocalGitExecOptions = {}
): Promise<T> {
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 exits non-zero when the MR is already closed — treat as success.
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: omitting both --squash and --rebase yields a regular merge commit.
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<GitLabDiscussionResolveResult> {
return withProjectRef<GitLabDiscussionResolveResult>(
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.
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<GitLabMRReviewersUpdateResult> {
return withProjectRef<GitLabMRReviewersUpdateResult>(
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<typeof mapGitLabReviewer>[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<GitLabJobTraceResult> {
return withProjectRef<GitLabJobTraceResult>(
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)
// A job with no log is an empty log, not a failure: surfacing the 404 would
// pin an error on the Checks row for a job canceled before it started.
if (isMissingJobLogError(msg)) {
return { ok: true, trace: '' }
}
return { ok: false, error: classifyJobLogError(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<GitLabRetryJobResult> {
return withProjectRef<GitLabRetryJobResult>(
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, listIssues } from './issues'
export { updateIssue } from './issue-update'
export { listAssignableUsers, listLabels } from './project-label-and-member-lookup'
// Re-exported so paste-URL call sites don't import getProjectRefForRemote from gl-utils directly.
export { getProjectRefForRemote }