mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
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.
1379 lines
43 KiB
TypeScript
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 }
|