diff --git a/src/main/bitbucket/client.test.ts b/src/main/bitbucket/client.test.ts new file mode 100644 index 00000000000..6d387468d29 --- /dev/null +++ b/src/main/bitbucket/client.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { getBitbucketAuthStatus, getBitbucketPullRequestForBranch } from './client' +import { _resetBitbucketRepoRefCache } from './repository-ref' + +const OLD_ENV = process.env + +function bitbucketPr(id = 7) { + return { + id, + title: 'Add Bitbucket', + state: 'OPEN', + updated_on: '2026-05-10T00:00:00.000Z', + links: { html: { href: `https://bitbucket.org/team/repo/pull-requests/${id}` } }, + source: { + branch: { name: 'feature/bitbucket' }, + commit: { hash: 'abc123' }, + repository: { full_name: 'team/repo' } + }, + destination: { + branch: { name: 'main' }, + repository: { full_name: 'team/repo' } + } + } +} + +describe('Bitbucket client', () => { + beforeEach(() => { + process.env = { ...OLD_ENV } + process.env.ORCA_BITBUCKET_API_BASE_URL = 'https://api.test.local/2.0' + process.env.ORCA_BITBUCKET_EMAIL = 'user@example.com' + process.env.ORCA_BITBUCKET_API_TOKEN = 'token' + delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'git@bitbucket.org:team/repo.git\n', + stderr: '' + }) + _resetBitbucketRepoRefCache() + vi.unstubAllGlobals() + }) + + it('fetches a branch pull request and commit build status', async () => { + const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => { + if (url.includes('/statuses/build')) { + return Response.json({ values: [{ state: 'SUCCESSFUL' }] }) + } + return Response.json({ values: [bitbucketPr()] }) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect( + getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/bitbucket') + ).resolves.toEqual({ + number: 7, + title: 'Add Bitbucket', + state: 'open', + url: 'https://bitbucket.org/team/repo/pull-requests/7', + status: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'UNKNOWN', + headSha: 'abc123' + }) + + const firstCall = fetchMock.mock.calls[0] + const listUrl = String(firstCall?.[0]) + const listInit = firstCall?.[1] + if (!listInit) { + throw new Error('expected request init') + } + const parsed = new URL(listUrl) + expect(parsed.pathname).toBe('/2.0/repositories/team/repo/pullrequests') + expect(parsed.searchParams.get('q')).toBe( + 'source.branch.name = "feature/bitbucket" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")' + ) + expect(parsed.searchParams.getAll('state')).toEqual([ + 'OPEN', + 'MERGED', + 'DECLINED', + 'SUPERSEDED' + ]) + expect((listInit.headers as Record).Authorization).toBe( + `Basic ${Buffer.from('user@example.com:token').toString('base64')}` + ) + }) + + it('falls back to a linked PR number when branch lookup misses', async () => { + const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => { + if (url.includes('/statuses/build')) { + return Response.json({ values: [] }) + } + if (url.endsWith('/pullrequests/42')) { + return Response.json(bitbucketPr(42)) + } + return Response.json({ values: [] }) + }) + vi.stubGlobal('fetch', fetchMock) + + await expect(getBitbucketPullRequestForBranch('/repo', 'different', 42)).resolves.toMatchObject( + { + number: 42, + status: 'neutral' + } + ) + }) + + it('reports env-token auth status through the Bitbucket /user endpoint', async () => { + const fetchMock = vi.fn(async () => Response.json({ username: 'bitbucket-user' })) + vi.stubGlobal('fetch', fetchMock) + + await expect(getBitbucketAuthStatus()).resolves.toEqual({ + configured: true, + authenticated: true, + account: 'bitbucket-user' + }) + }) + + it('accepts T3Code-compatible Bitbucket environment variable names', async () => { + delete process.env.ORCA_BITBUCKET_EMAIL + delete process.env.ORCA_BITBUCKET_API_TOKEN + process.env.T3CODE_BITBUCKET_EMAIL = 't3@example.com' + process.env.T3CODE_BITBUCKET_API_TOKEN = 't3-token' + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => + Response.json({ username: 't3-user' }) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(getBitbucketAuthStatus()).resolves.toEqual({ + configured: true, + authenticated: true, + account: 't3-user' + }) + const init = fetchMock.mock.calls[0]?.[1] + if (!init) { + throw new Error('expected request init') + } + expect((init.headers as Record).Authorization).toBe( + `Basic ${Buffer.from('t3@example.com:t3-token').toString('base64')}` + ) + }) +}) diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts new file mode 100644 index 00000000000..097a8915d06 --- /dev/null +++ b/src/main/bitbucket/client.ts @@ -0,0 +1,224 @@ +import { Buffer } from 'buffer' +import type { CheckStatus } from '../../shared/types' +import { + deriveBitbucketBuildStatus, + mapBitbucketPullRequest, + type BitbucketPullRequestInfo, + type RawBitbucketBuildStatus, + type RawBitbucketPullRequest +} from './pull-request-mappers' +import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref' + +const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0' +const REQUEST_TIMEOUT_MS = 5000 +const ALL_PULL_REQUEST_STATES = ['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED'] as const + +type BitbucketAuthConfig = { + baseUrl: string + accessToken: string | null + email: string | null + apiToken: string | null +} + +export type BitbucketAuthStatus = { + configured: boolean + authenticated: boolean + account: string | null +} + +type RequestOptions = { + searchParams?: Record + timeoutMs?: number +} + +function envValue(primary: string, fallback: string): string | null { + const value = process.env[primary]?.trim() || process.env[fallback]?.trim() || '' + return value.length > 0 ? value : null +} + +function getAuthConfig(): BitbucketAuthConfig { + return { + baseUrl: + envValue('ORCA_BITBUCKET_API_BASE_URL', 'T3CODE_BITBUCKET_API_BASE_URL') ?? + DEFAULT_API_BASE_URL, + accessToken: envValue('ORCA_BITBUCKET_ACCESS_TOKEN', 'T3CODE_BITBUCKET_ACCESS_TOKEN'), + email: envValue('ORCA_BITBUCKET_EMAIL', 'T3CODE_BITBUCKET_EMAIL'), + apiToken: envValue('ORCA_BITBUCKET_API_TOKEN', 'T3CODE_BITBUCKET_API_TOKEN') + } +} + +function hasAuth(config: BitbucketAuthConfig): boolean { + return Boolean(config.accessToken || (config.email && config.apiToken)) +} + +function authHeaders(config: BitbucketAuthConfig): Record { + if (config.accessToken) { + return { Authorization: `Bearer ${config.accessToken}` } + } + if (config.email && config.apiToken) { + const encoded = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64') + return { Authorization: `Basic ${encoded}` } + } + return {} +} + +function isStringArray(value: string | readonly string[]): value is readonly string[] { + return Array.isArray(value) +} + +function apiUrl(path: string, searchParams?: RequestOptions['searchParams']): string { + const config = getAuthConfig() + const base = config.baseUrl.replace(/\/+$/, '') + const url = new URL(`${base}${path}`) + if (searchParams) { + for (const [key, value] of Object.entries(searchParams)) { + if (isStringArray(value)) { + for (const item of value) { + url.searchParams.append(key, item) + } + } else { + url.searchParams.set(key, value) + } + } + } + return url.toString() +} + +async function requestJson(path: string, options: RequestOptions = {}): Promise { + const config = getAuthConfig() + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? REQUEST_TIMEOUT_MS) + try { + const response = await fetch(apiUrl(path, options.searchParams), { + headers: { + Accept: 'application/json', + ...authHeaders(config) + }, + signal: controller.signal + }) + if (!response.ok) { + return null + } + return (await response.json()) as T + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + +function encodedRepoPath(repo: BitbucketRepoRef): string { + return `${encodeURIComponent(repo.workspace)}/${encodeURIComponent(repo.repoSlug)}` +} + +function escapeBitbucketQueryString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +function allStateFilter(): string { + return `(${ALL_PULL_REQUEST_STATES.map((state) => `state = "${state}"`).join(' OR ')})` +} + +async function getBuildStatus( + repo: BitbucketRepoRef, + headSha: string | undefined +): Promise { + if (!headSha) { + return 'neutral' + } + const data = await requestJson<{ values?: RawBitbucketBuildStatus[] }>( + `/repositories/${encodedRepoPath(repo)}/commit/${encodeURIComponent(headSha)}/statuses/build`, + { searchParams: { pagelen: '100' } } + ) + return deriveBitbucketBuildStatus(data?.values ?? []) +} + +async function normalizePullRequest( + repo: BitbucketRepoRef, + raw: RawBitbucketPullRequest +): Promise { + const headSha = raw.source?.commit?.hash?.trim() + const status = await getBuildStatus(repo, headSha) + return mapBitbucketPullRequest(raw, status) +} + +export async function getBitbucketAuthStatus(): Promise { + const config = getAuthConfig() + if (!hasAuth(config)) { + return { configured: false, authenticated: false, account: null } + } + const user = await requestJson<{ + username?: string | null + display_name?: string | null + account_id?: string | null + }>('/user', { timeoutMs: 4000 }) + return { + configured: true, + authenticated: user !== null, + account: user?.username ?? user?.display_name ?? user?.account_id ?? null + } +} + +export async function getBitbucketPullRequest( + repoPath: string, + prNumber: number +): Promise { + const repo = await getBitbucketRepoRef(repoPath) + if (!repo) { + return null + } + const raw = await requestJson( + `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(prNumber))}` + ) + return raw ? normalizePullRequest(repo, raw) : null +} + +export async function getBitbucketPullRequestForBranch( + repoPath: string, + branch: string, + linkedPRNumber?: number | null +): Promise { + const branchName = branch.replace(/^refs\/heads\//, '') + if (!branchName && linkedPRNumber == null) { + return null + } + + const repo = await getBitbucketRepoRef(repoPath) + if (!repo) { + return null + } + + if (branchName) { + const query = [ + `source.branch.name = "${escapeBitbucketQueryString(branchName)}"`, + allStateFilter() + ].join(' AND ') + const list = await requestJson<{ values?: RawBitbucketPullRequest[] }>( + `/repositories/${encodedRepoPath(repo)}/pullrequests`, + { + searchParams: { + pagelen: '1', + sort: '-updated_on', + q: query, + state: ALL_PULL_REQUEST_STATES + } + } + ) + const raw = list?.values?.[0] + if (raw) { + return normalizePullRequest(repo, raw) + } + } + + if (typeof linkedPRNumber !== 'number') { + return null + } + const raw = await requestJson( + `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}` + ) + return raw ? normalizePullRequest(repo, raw) : null +} + +export async function getBitbucketRepoSlug(repoPath: string): Promise { + return getBitbucketRepoRef(repoPath) +} diff --git a/src/main/bitbucket/pull-request-mappers.test.ts b/src/main/bitbucket/pull-request-mappers.test.ts new file mode 100644 index 00000000000..fe329dfb181 --- /dev/null +++ b/src/main/bitbucket/pull-request-mappers.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + deriveBitbucketBuildStatus, + mapBitbucketPullRequest, + mapBitbucketPullRequestState +} from './pull-request-mappers' + +describe('Bitbucket pull request mappers', () => { + it('normalizes Bitbucket pull request states', () => { + expect(mapBitbucketPullRequestState('OPEN')).toBe('open') + expect(mapBitbucketPullRequestState('MERGED')).toBe('merged') + expect(mapBitbucketPullRequestState('DECLINED')).toBe('closed') + expect(mapBitbucketPullRequestState('SUPERSEDED')).toBe('closed') + }) + + it('derives Orca check status from Bitbucket build statuses', () => { + expect(deriveBitbucketBuildStatus([])).toBe('neutral') + expect(deriveBitbucketBuildStatus([{ state: 'SUCCESSFUL' }])).toBe('success') + expect(deriveBitbucketBuildStatus([{ state: 'INPROGRESS' }])).toBe('pending') + expect(deriveBitbucketBuildStatus([{ state: 'FAILED' }])).toBe('failure') + }) + + it('maps raw pull request JSON into the shared PR-like shape', () => { + expect( + mapBitbucketPullRequest( + { + id: 42, + title: 'Add Bitbucket', + state: 'MERGED', + updated_on: '2026-05-10T00:00:00.000Z', + links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/42' } }, + source: { branch: { name: 'feature' }, commit: { hash: 'abc123' } }, + destination: { branch: { name: 'main' } } + }, + 'success' + ) + ).toEqual({ + number: 42, + title: 'Add Bitbucket', + state: 'merged', + url: 'https://bitbucket.org/team/repo/pull-requests/42', + status: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'UNKNOWN', + headSha: 'abc123' + }) + }) +}) diff --git a/src/main/bitbucket/pull-request-mappers.ts b/src/main/bitbucket/pull-request-mappers.ts new file mode 100644 index 00000000000..92c99d7f0a4 --- /dev/null +++ b/src/main/bitbucket/pull-request-mappers.ts @@ -0,0 +1,95 @@ +import type { CheckStatus, PRMergeableState } from '../../shared/types' + +export type RawBitbucketPullRequest = { + id?: number + title?: string + state?: string | null + updated_on?: string | null + links?: { + html?: { + href?: string + } + } + source?: { + branch?: { + name?: string + } + commit?: { + hash?: string + } | null + } + destination?: { + branch?: { + name?: string + } + } +} + +export type BitbucketPullRequestInfo = { + number: number + title: string + state: 'open' | 'closed' | 'merged' + url: string + status: CheckStatus + updatedAt: string + mergeable: PRMergeableState + headSha?: string +} + +export type RawBitbucketBuildStatus = { + state?: string | null +} + +export function mapBitbucketPullRequestState( + state: string | null | undefined +): BitbucketPullRequestInfo['state'] { + switch (state?.trim().toUpperCase()) { + case 'MERGED': + return 'merged' + case 'DECLINED': + case 'SUPERSEDED': + return 'closed' + case 'OPEN': + default: + return 'open' + } +} + +export function deriveBitbucketBuildStatus( + statuses: readonly RawBitbucketBuildStatus[] +): CheckStatus { + if (statuses.length === 0) { + return 'neutral' + } + const states = statuses.map((status) => status.state?.trim().toUpperCase() ?? '') + if (states.some((state) => state === 'FAILED' || state === 'STOPPED' || state === 'ERROR')) { + return 'failure' + } + if (states.some((state) => state === 'INPROGRESS' || state === 'PENDING')) { + return 'pending' + } + if (states.every((state) => state === 'SUCCESSFUL')) { + return 'success' + } + return 'neutral' +} + +export function mapBitbucketPullRequest( + raw: RawBitbucketPullRequest, + status: CheckStatus +): BitbucketPullRequestInfo | null { + if (typeof raw.id !== 'number' || !raw.title || !raw.links?.html?.href) { + return null + } + const headSha = raw.source?.commit?.hash?.trim() + return { + number: raw.id, + title: raw.title, + state: mapBitbucketPullRequestState(raw.state), + url: raw.links.html.href, + status, + updatedAt: raw.updated_on ?? '', + mergeable: 'UNKNOWN', + ...(headSha ? { headSha } : {}) + } +} diff --git a/src/main/bitbucket/repository-ref.test.ts b/src/main/bitbucket/repository-ref.test.ts new file mode 100644 index 00000000000..6ea77cf415a --- /dev/null +++ b/src/main/bitbucket/repository-ref.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { + _resetBitbucketRepoRefCache, + getBitbucketRepoRef, + parseBitbucketRepoRef +} from './repository-ref' + +describe('Bitbucket repository refs', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + _resetBitbucketRepoRefCache() + }) + + it('parses HTTPS, SSH, and ssh:// Bitbucket remotes', () => { + expect(parseBitbucketRepoRef('https://bitbucket.org/team/project.git')).toEqual({ + workspace: 'team', + repoSlug: 'project' + }) + expect(parseBitbucketRepoRef('git@bitbucket.org:team/project.git')).toEqual({ + workspace: 'team', + repoSlug: 'project' + }) + expect(parseBitbucketRepoRef('ssh://git@bitbucket.org/team/project.git')).toEqual({ + workspace: 'team', + repoSlug: 'project' + }) + expect(parseBitbucketRepoRef('https://github.com/team/project.git')).toBeNull() + }) + + it('resolves origin through the WSL-aware git runner and caches the result', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'git@bitbucket.org:team/project.git\n', + stderr: '' + }) + + await expect(getBitbucketRepoRef('/repo')).resolves.toEqual({ + workspace: 'team', + repoSlug: 'project' + }) + await expect(getBitbucketRepoRef('/repo')).resolves.toEqual({ + workspace: 'team', + repoSlug: 'project' + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + }) +}) diff --git a/src/main/bitbucket/repository-ref.ts b/src/main/bitbucket/repository-ref.ts new file mode 100644 index 00000000000..b51b7c1371c --- /dev/null +++ b/src/main/bitbucket/repository-ref.ts @@ -0,0 +1,76 @@ +import { gitExecFileAsync } from '../git/runner' + +export type BitbucketRepoRef = { + workspace: string + repoSlug: string +} + +const repoRefCache = new Map() + +/** @internal - exposed for tests only */ +export function _resetBitbucketRepoRefCache(): void { + repoRefCache.clear() +} + +function parseBitbucketPath(pathname: string): BitbucketRepoRef | null { + const withoutSuffix = pathname.replace(/\.git$/i, '') + const parts = withoutSuffix + .split('/') + .map((part) => part.trim()) + .filter(Boolean) + if (parts.length < 2) { + return null + } + const workspace = parts.at(-2) + const repoSlug = parts.at(-1) + if (!workspace || !repoSlug) { + return null + } + return { + workspace: decodeURIComponent(workspace), + repoSlug: decodeURIComponent(repoSlug) + } +} + +export function parseBitbucketRepoRef(remoteUrl: string): BitbucketRepoRef | null { + const trimmed = remoteUrl.trim() + const scpLike = trimmed.match(/^(?:[^@]+@)?bitbucket\.org:([^\s]+?)(?:\.git)?$/i) + if (scpLike) { + return parseBitbucketPath(scpLike[1]) + } + + try { + const url = new URL(trimmed) + if (url.hostname.toLowerCase() !== 'bitbucket.org') { + return null + } + return parseBitbucketPath(url.pathname) + } catch { + return null + } +} + +export async function getBitbucketRepoRefForRemote( + repoPath: string, + remoteName: string +): Promise { + const cacheKey = `${repoPath}\0${remoteName}` + if (repoRefCache.has(cacheKey)) { + return repoRefCache.get(cacheKey)! + } + try { + const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { + cwd: repoPath + }) + const result = parseBitbucketRepoRef(stdout) + repoRefCache.set(cacheKey, result) + return result + } catch { + repoRefCache.set(cacheKey, null) + return null + } +} + +export async function getBitbucketRepoRef(repoPath: string): Promise { + return getBitbucketRepoRefForRemote(repoPath, 'origin') +} diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 1045c14980b..c589b7b20a1 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: command routing, WSL translation, and +git/gh/glab wrappers must stay co-located so platform behavior remains +consistent across every repo-scoped subprocess call. */ /** * Centralized git/gh/command runner with transparent WSL support. * @@ -467,6 +470,58 @@ export async function ghExecFileAsync( throw lastError } +// ─── glab CLI runner ──────────────────────────────────────────────── +// Why: parallel to gh CLI runner above. GitLab support is added by +// cloning gh's surface rather than abstracting both behind a generic +// runner — keeping them as parallel implementations matches the +// project's clone-and-adapt approach for new providers and avoids +// touching the working gh path. Reuses the shared retry/transient +// helpers since HTTP-status- and TCP-error-based classification is +// provider-agnostic. + +type GlabExecOptions = Omit & { cwd?: string; wslDistro?: string } + +/** + * Async glab CLI execution. Drop-in replacement for + * `execFileAsync('glab', args, { cwd, encoding, ... })`. + * + * Retry policy mirrors ghExecFileAsync. + */ +export async function glabExecFileAsync( + args: string[], + options: GlabExecOptions = {} +): Promise<{ stdout: string; stderr: string }> { + const resolved = resolveCommand('glab', args, options.cwd, options.wslDistro) + let lastError: unknown + for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { + try { + const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + env: options.env + }) + return { stdout: stdout as string, stderr: stderr as string } + } catch (err) { + lastError = err + const { stderr } = extractExecError(err) + const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length + if (!isLastAttempt && isTransientGhError(stderr)) { + const retryAfterMs = parseRetryAfterMs(stderr) + const delayMs = + retryAfterMs !== null + ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) + : GH_RETRY_DELAYS_MS[attempt] + await sleep(delayMs) + continue + } + throw err + } + } + throw lastError +} + // ─── Generic command runner (for rg, etc.) ────────────────────────── /** diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts new file mode 100644 index 00000000000..1d84730ed1a --- /dev/null +++ b/src/main/gitlab/client-mr.test.ts @@ -0,0 +1,308 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GlUtils from './gl-utils' + +const { + glabExecFileAsyncMock, + glabApiWithHeadersMock, + getGlabKnownHostsMock, + getProjectRefMock, + resolveIssueSourceMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + glabExecFileAsyncMock: vi.fn(), + glabApiWithHeadersMock: vi.fn(), + getGlabKnownHostsMock: vi.fn(), + getProjectRefMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gl-utils', async () => { + const actual = await vi.importActual('./gl-utils') + return { + ...actual, + glabExecFileAsync: glabExecFileAsyncMock, + glabApiWithHeaders: glabApiWithHeadersMock, + getGlabKnownHosts: getGlabKnownHostsMock, + getProjectRef: getProjectRefMock, + resolveIssueSource: resolveIssueSourceMock, + acquire: acquireMock, + release: releaseMock + } +}) + +import { getMergeRequest, getMergeRequestForBranch, listMergeRequests } from './client' + +describe('gitlab client — MR operations', () => { + beforeEach(() => { + glabExecFileAsyncMock.mockReset() + glabApiWithHeadersMock.mockReset() + getGlabKnownHostsMock.mockReset() + getProjectRefMock.mockReset() + resolveIssueSourceMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + getGlabKnownHostsMock.mockResolvedValue(['gitlab.com']) + }) + + describe('getMergeRequest', () => { + it('fetches the MR with rolled-up pipeline status', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 10, + title: 'Add feature', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/merge_requests/10', + updated_at: '2026-05-05T00:00:00Z', + sha: 'deadbeef', + head_pipeline: { status: 'success' }, + detailed_merge_status: 'mergeable' + }) + }) + const mr = await getMergeRequest('/repo', 10) + expect(mr).toMatchObject({ + number: 10, + title: 'Add feature', + state: 'opened', + url: 'https://gitlab.com/g/p/-/merge_requests/10', + pipelineStatus: 'success', + mergeable: 'MERGEABLE', + headSha: 'deadbeef' + }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/g%2Fp/merge_requests/10'], + { cwd: '/repo' } + ) + }) + + it('falls back to `glab mr view` when project ref is unresolved', async () => { + getProjectRefMock.mockResolvedValueOnce(null) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ iid: 5, title: 't', state: 'opened' }) + }) + await getMergeRequest('/repo', 5) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['mr', 'view', '5', '--output', 'json'], { + cwd: '/repo' + }) + }) + + it('returns null when glab errors', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not found')) + await expect(getMergeRequest('/repo', 99)).resolves.toBeNull() + }) + + it('treats neutral pipeline (no head_pipeline) as neutral status', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 1, + title: 't', + state: 'opened', + head_pipeline: null + }) + }) + const mr = await getMergeRequest('/repo', 1) + expect(mr?.pipelineStatus).toBe('neutral') + }) + }) + + describe('getMergeRequestForBranch', () => { + it('finds the most recently updated MR for a branch across states', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + iid: 7, + title: 'WIP', + state: 'merged', + sha: 'abc', + head_pipeline: { status: 'success' } + } + ]) + }) + + const mr = await getMergeRequestForBranch('/repo', 'feature/foo') + expect(mr?.number).toBe(7) + expect(mr?.state).toBe('merged') + expect(mr?.pipelineStatus).toBe('success') + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + 'projects/g%2Fp/merge_requests?source_branch=feature%2Ffoo&order_by=updated_at&sort=desc&per_page=1' + ], + { cwd: '/repo' } + ) + }) + + it('strips refs/heads/ prefix from the branch arg', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await getMergeRequestForBranch('/repo', 'refs/heads/feature/bar') + const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[] + expect(callArgs[1]).toContain('source_branch=feature%2Fbar') + }) + + it('returns null when no MR matches the branch', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + await expect(getMergeRequestForBranch('/repo', 'feature')).resolves.toBeNull() + }) + + it('falls back to a linked MR iid when the branch lookup misses', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 9, + title: 'Linked MR', + state: 'opened', + pipeline: { status: 'success' } + }) + }) + + const mr = await getMergeRequestForBranch('/repo', 'local-review-branch', 9) + expect(mr?.number).toBe(9) + expect(mr?.pipelineStatus).toBe('success') + expect(glabExecFileAsyncMock).toHaveBeenLastCalledWith( + ['api', 'projects/g%2Fp/merge_requests/9'], + { cwd: '/repo' } + ) + }) + + it('returns null for an empty / detached-HEAD branch arg', async () => { + // Why: during a rebase the branch is empty — mirror github/getPRForBranch's + // early return without calling glab. + await expect(getMergeRequestForBranch('/repo', '')).resolves.toBeNull() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('returns null when project ref cannot be resolved', async () => { + getProjectRefMock.mockResolvedValueOnce(null) + await expect(getMergeRequestForBranch('/repo', 'feature')).resolves.toBeNull() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) + }) + + describe('listMergeRequests', () => { + beforeEach(() => { + resolveIssueSourceMock.mockImplementation(async () => ({ + source: await getProjectRefMock(), + fellBack: false + })) + }) + + it('paginates with X-Total / X-Total-Pages', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify([ + { + id: 100, + iid: 1, + title: 'first', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/merge_requests/1', + updated_at: '2026-05-05', + source_branch: 'feat-1', + target_branch: 'main', + author: { username: 'alice' }, + source_project_id: 5, + target_project_id: 5 + } + ]), + headers: { 'x-total': '42', 'x-total-pages': '3' } + }) + + const result = await listMergeRequests('/repo', 'opened', 1, 20) + expect(result.items).toHaveLength(1) + expect(result.items[0]).toMatchObject({ + type: 'mr', + number: 1, + title: 'first', + state: 'opened', + branchName: 'feat-1', + baseRefName: 'main', + author: 'alice', + isCrossRepository: false, + repoId: 'g/p' + }) + expect(result.totalCount).toBe(42) + expect(result.totalPages).toBe(3) + expect(result.page).toBe(1) + }) + + it("omits the state param when state='all'", async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + + await listMergeRequests('/repo', 'all', 1, 20) + const callPath = glabApiWithHeadersMock.mock.calls[0][0][0] as string + expect(callPath).not.toContain('state=') + }) + + it('passes through Open / Merged / Closed states', async () => { + for (const state of ['opened', 'merged', 'closed'] as const) { + glabApiWithHeadersMock.mockReset() + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + await listMergeRequests('/repo', state, 1, 20) + const callPath = glabApiWithHeadersMock.mock.calls[0][0][0] as string + expect(callPath).toContain(`state=${state}`) + } + }) + + it('flags fork MRs as cross-repository', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify([ + { + id: 200, + iid: 2, + title: 'fork mr', + state: 'opened', + source_branch: 'feat', + target_branch: 'main', + // Different source/target = fork MR + source_project_id: 11, + target_project_id: 5 + } + ]), + headers: { 'x-total': '1', 'x-total-pages': '1' } + }) + + const result = await listMergeRequests('/repo', 'opened', 1, 20) + expect(result.items[0].isCrossRepository).toBe(true) + }) + + it('returns a not_found error envelope when project ref is unresolved', async () => { + getProjectRefMock.mockResolvedValueOnce(null) + const result = await listMergeRequests('/repo', 'opened') + expect(result.error?.type).toBe('not_found') + expect(result.items).toEqual([]) + expect(glabApiWithHeadersMock).not.toHaveBeenCalled() + }) + + it('falls back to ceil(total/perPage) when x-total-pages is absent', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: '[]', + headers: { 'x-total': '57' } + }) + const result = await listMergeRequests('/repo', 'opened', 1, 20) + expect(result.totalCount).toBe(57) + expect(result.totalPages).toBe(3) + }) + + it('classifies API errors into the result envelope', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) + const result = await listMergeRequests('/repo', 'opened') + expect(result.error?.type).toBe('permission_denied') + expect(result.items).toEqual([]) + }) + }) +}) diff --git a/src/main/gitlab/client-work-items.test.ts b/src/main/gitlab/client-work-items.test.ts new file mode 100644 index 00000000000..7a060af5b67 --- /dev/null +++ b/src/main/gitlab/client-work-items.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GlUtils from './gl-utils' + +const { + glabExecFileAsyncMock, + glabApiWithHeadersMock, + getGlabKnownHostsMock, + getProjectRefMock, + resolveIssueSourceMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + glabExecFileAsyncMock: vi.fn(), + glabApiWithHeadersMock: vi.fn(), + getGlabKnownHostsMock: vi.fn(), + getProjectRefMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gl-utils', async () => { + const actual = await vi.importActual('./gl-utils') + return { + ...actual, + glabExecFileAsync: glabExecFileAsyncMock, + glabApiWithHeaders: glabApiWithHeadersMock, + getGlabKnownHosts: getGlabKnownHostsMock, + getProjectRef: getProjectRefMock, + resolveIssueSource: resolveIssueSourceMock, + acquire: acquireMock, + release: releaseMock + } +}) + +import { listWorkItems } from './client' + +describe('gitlab client — combined listWorkItems', () => { + beforeEach(() => { + glabExecFileAsyncMock.mockReset() + glabApiWithHeadersMock.mockReset() + getGlabKnownHostsMock.mockReset() + getProjectRefMock.mockReset() + resolveIssueSourceMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + getGlabKnownHostsMock.mockResolvedValue(['gitlab.com']) + resolveIssueSourceMock.mockImplementation(async () => ({ + source: { host: 'gitlab.com', path: 'g/p' }, + fellBack: false + })) + }) + + it('merges MRs + issues and sorts by updatedAt desc', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: JSON.stringify([ + { + id: 100, + iid: 1, + title: 'older mr', + state: 'opened', + updated_at: '2026-05-05T00:00:00Z', + source_project_id: 5, + target_project_id: 5 + } + ]), + headers: { 'x-total': '1', 'x-total-pages': '1' } + }) + // Why: listIssues calls glabExecFileAsync (not glabApiWithHeaders) — + // it reads the issues list endpoint via the regular `glab api` path. + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + id: 200, + iid: 5, + title: 'newer issue', + state: 'opened', + updated_at: '2026-05-08T00:00:00Z' + } + ]) + }) + + const result = await listWorkItems('/repo', 'opened', 1, 20) + expect(result.items.map((i) => i.title)).toEqual(['newer issue', 'older mr']) + expect(result.items[0].type).toBe('issue') + expect(result.items[1].type).toBe('mr') + }) + + it("skips the issues fetch when state === 'merged'", async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ + body: '[]', + headers: { 'x-total': '0', 'x-total-pages': '0' } + }) + + await listWorkItems('/repo', 'merged', 1, 20) + // Why: the merged-state filter doesn't apply to issues (issues + // don't have a merged lifecycle), so the IPC must not even spawn + // the issues read. Verifies the listIssues path was not taken. + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('passes the closed state through to the issues fetch', async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo', 'closed', 1, 20) + const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[] + expect(issuesCallPath[1]).toContain('state=closed') + }) + + it("omits the state param when 'all'", async () => { + glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo', 'all', 1, 20) + const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[] + expect(issuesCallPath[1]).not.toContain('state=') + }) + + it('returns a not_found error envelope when project ref is unresolved', async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false }) + + const result = await listWorkItems('/repo', 'opened') + expect(result.error?.type).toBe('not_found') + expect(result.items).toEqual([]) + expect(glabApiWithHeadersMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('surfaces the MR error envelope into the combined result', async () => { + glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + const result = await listWorkItems('/repo', 'opened', 1, 20) + expect(result.error?.type).toBe('permission_denied') + }) + + it('still returns issues when MRs error out', async () => { + glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 500')) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { id: 200, iid: 9, title: 'live issue', state: 'opened', updated_at: '2026-05-08' } + ]) + }) + + const result = await listWorkItems('/repo', 'opened', 1, 20) + expect(result.items).toHaveLength(1) + expect(result.items[0].title).toBe('live issue') + expect(result.error).toBeDefined() + }) +}) diff --git a/src/main/gitlab/client.test.ts b/src/main/gitlab/client.test.ts new file mode 100644 index 00000000000..7cf02eb5218 --- /dev/null +++ b/src/main/gitlab/client.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GlUtils from './gl-utils' + +const { glabExecFileAsyncMock, getGlabKnownHostsMock, acquireMock, releaseMock } = vi.hoisted( + () => ({ + glabExecFileAsyncMock: vi.fn(), + getGlabKnownHostsMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() + }) +) + +vi.mock('./gl-utils', async () => { + const actual = await vi.importActual('./gl-utils') + return { + ...actual, + glabExecFileAsync: glabExecFileAsyncMock, + getGlabKnownHosts: getGlabKnownHostsMock, + acquire: acquireMock, + release: releaseMock + } +}) + +import { getAuthenticatedViewer, getWorkItemByProjectRef, listTodos } from './client' + +describe('gitlab client — viewer & paste-URL lookup', () => { + beforeEach(() => { + glabExecFileAsyncMock.mockReset() + getGlabKnownHostsMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + getGlabKnownHostsMock.mockResolvedValue(['gitlab.com']) + }) + + describe('getAuthenticatedViewer', () => { + it('returns username + email when glab api user succeeds', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ username: 'alice', email: 'alice@example.com' }) + }) + await expect(getAuthenticatedViewer()).resolves.toEqual({ + username: 'alice', + email: 'alice@example.com' + }) + }) + + it('coerces a missing email to null', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ username: 'alice', email: null }) + }) + await expect(getAuthenticatedViewer()).resolves.toEqual({ + username: 'alice', + email: null + }) + }) + + it('returns null when glab fails', async () => { + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not authenticated')) + await expect(getAuthenticatedViewer()).resolves.toBeNull() + }) + + it('returns null when username is empty', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ username: ' ', email: null }) + }) + await expect(getAuthenticatedViewer()).resolves.toBeNull() + }) + }) + + describe('getWorkItemByProjectRef', () => { + it('fetches an MR and maps to GitLabWorkItem', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 100, + iid: 5, + title: 't', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/merge_requests/5', + source_branch: 'feat', + target_branch: 'main' + }) + }) + const item = await getWorkItemByProjectRef( + '/repo', + { host: 'gitlab.com', path: 'g/p' }, + 5, + 'mr' + ) + expect(item).toMatchObject({ type: 'mr', number: 5, branchName: 'feat' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/g%2Fp/merge_requests/5'], + { cwd: '/repo' } + ) + }) + + it('fetches an issue and maps to GitLabWorkItem', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 200, + iid: 9, + title: 'bug', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/issues/9' + }) + }) + const item = await getWorkItemByProjectRef( + '/repo', + { host: 'gitlab.com', path: 'g/p' }, + 9, + 'issue' + ) + expect(item).toMatchObject({ type: 'issue', number: 9 }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['api', 'projects/g%2Fp/issues/9'], { + cwd: '/repo' + }) + }) + + it('returns null when the API errors', async () => { + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not found')) + const item = await getWorkItemByProjectRef( + '/repo', + { host: 'gitlab.com', path: 'g/p' }, + 9, + 'issue' + ) + expect(item).toBeNull() + }) + }) + + describe('listTodos', () => { + it('maps glab todos response to GitLabTodo shape', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + id: 1, + action_name: 'assigned', + target_type: 'MergeRequest', + target: { + iid: 42, + title: 'Add feature', + web_url: 'https://gitlab.com/g/p/-/merge_requests/42' + }, + target_url: 'https://gitlab.com/g/p/-/merge_requests/42', + author: { username: 'alice', avatar_url: 'https://example.com/a.png' }, + project: { path_with_namespace: 'g/p' }, + updated_at: '2026-05-08T10:00:00Z', + state: 'pending' + } + ]) + }) + + await expect(listTodos('/repo')).resolves.toEqual([ + { + id: 1, + actionName: 'assigned', + targetType: 'MergeRequest', + targetIid: 42, + targetTitle: 'Add feature', + targetUrl: 'https://gitlab.com/g/p/-/merge_requests/42', + projectPath: 'g/p', + authorUsername: 'alice', + authorAvatarUrl: 'https://example.com/a.png', + updatedAt: '2026-05-08T10:00:00Z', + state: 'pending' + } + ]) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', '--paginate', 'todos?state=pending&per_page=50'], + { cwd: '/repo' } + ) + }) + + it('coerces non-pending state values to pending (defensive)', async () => { + // Why: we filter to state=pending in the request, but if a future + // glab change leaks a different state through, the type's narrow + // 'pending' | 'done' union should still hold — anything not 'done' + // collapses to 'pending' rather than violating the type. + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { id: 2, action_name: 'mentioned', target_type: 'Issue', state: 'weird' } + ]) + }) + const result = await listTodos('/repo') + expect(result[0].state).toBe('pending') + }) + + it('falls back to empty list when glab errors', async () => { + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('auth failed')) + await expect(listTodos('/repo')).resolves.toEqual([]) + }) + + it('handles missing target / project / author fields gracefully', async () => { + // Why: GitLab Todos for Commit / Note targets sometimes omit + // `target` entirely — defaults must keep the record well-formed + // so the renderer doesn't choke on .title access. + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([{ id: 3, action_name: 'build_failed', target_type: 'Commit' }]) + }) + const result = await listTodos('/repo') + expect(result[0]).toMatchObject({ + targetIid: null, + targetTitle: '', + targetUrl: '', + projectPath: '', + authorUsername: '', + authorAvatarUrl: '' + }) + }) + }) +}) diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts new file mode 100644 index 00000000000..7b5f9e30b59 --- /dev/null +++ b/src/main/gitlab/client.ts @@ -0,0 +1,619 @@ +/* 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, + GitLabPagedResult, + GitLabTodo, + GitLabViewer, + GitLabWorkItem, + IssueSourcePreference, + ListMergeRequestsResult, + MRComment, + MRInfo, + MRListState +} from '../../shared/types' +import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers' +import { + acquire, + classifyListIssuesError, + getGlabKnownHosts, + getProjectRef, + getProjectRefForRemote, + glabApiWithHeaders, + glabExecFileAsync, + release, + resolveIssueSource, + type ProjectRef +} from './gl-utils' +import type { IssueListState } from './issues' + +// 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) +} + +/** + * 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() + } +} + +/** + * 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): Promise { + const knownHosts = await getGlabKnownHosts() + return getProjectRef(repoPath, knownHosts) +} + +/** + * 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): Promise { + const knownHosts = await getGlabKnownHosts() + const projectRef = await getProjectRef(repoPath, knownHosts) + await acquire() + try { + const args = projectRef + ? ['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`] + : ['mr', 'view', String(iid), '--output', 'json'] + const { stdout } = await glabExecFileAsync(args, { cwd: repoPath }) + 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 +): Promise { + const branchName = branch.replace(/^refs\/heads\//, '') + if (!branchName && linkedMRIid == null) { + return null + } + const knownHosts = await getGlabKnownHosts() + const projectRef = await getProjectRef(repoPath, knownHosts) + if (!projectRef) { + return null + } + await acquire() + try { + if (branchName) { + const { stdout } = await glabExecFileAsync( + [ + 'api', + `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1` + ], + { cwd: repoPath } + ) + const data = JSON.parse(stdout) as (Parameters[0] & { + head_pipeline?: { status?: string } | null + })[] + if (Array.isArray(data) && data.length > 0) { + const raw = data[0] + const pipelineStatus = derivePipelineStatus(raw.head_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', `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}`], + { cwd: repoPath } + ) + 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 { + return null + } finally { + release() + } +} + +/** + * List merge requests for a project with strict pagination. Returns + * total counts pulled from X-Total / X-Total-Pages response headers so + * callers can render "Page X of Y" UIs. + */ +export async function listMergeRequests( + repoPath: string, + state: MRListState = 'opened', + page = 1, + perPage = 20, + preference?: IssueSourcePreference +): Promise { + const knownHosts = await getGlabKnownHosts() + // 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) + if (!projectRef) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: { + type: 'not_found', + message: 'No GitLab project found for this repository.' + } + } + } + // 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 path = + `projects/${encodedProject(projectRef.path)}/merge_requests?` + + `page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}` + const repoId = projectRef.path + + await acquire() + try { + const { body, headers } = await glabApiWithHeaders([path], { cwd: repoPath }) + const data = JSON.parse(body) as Parameters[0][] + return { + items: data.map((d) => mapMRToWorkItem(d, repoId)), + 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' +): Promise { + await acquire() + try { + const resource = type === 'mr' ? 'merge_requests' : 'issues' + const { stdout } = await glabExecFileAsync( + ['api', `projects/${encodedProject(projectRef.path)}/${resource}/${iid}`], + { cwd: repoPath } + ) + const data = JSON.parse(stdout) + if (type === 'mr') { + return mapMRToWorkItem(data, projectRef.path) + } + return mapIssueToWorkItem(data, projectRef.path) + } catch { + return null + } finally { + release() + } +} + +// Why: combined MR + issue list for the Tasks-screen and picker +// surfaces. Centralizes the merge logic that TaskPage previously did +// inline so the IPC layer has a single function to call. Pagination is +// approximate — the v1 contract is "page 1 of perPage MRs + perPage +// issues, mixed by updatedAt desc" which is good enough for a typical +// project's <100 active items. +export type ListWorkItemsState = MRListState + +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 +): Promise> { + const issueState = mrStateToIssueState(state) + const knownHosts = await getGlabKnownHosts() + const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + 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), + issueState === null + ? Promise.resolve({ + items: [] as GitLabWorkItem[], + error: undefined as ClassifiedError | undefined + }) + : fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage) + ]) + 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 } : {}) + } +} + +async function fetchIssuesAsWorkItems( + repoPath: string, + projectRef: ProjectRef, + state: IssueListState, + perPage: number +): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> { + await acquire() + try { + const stateParam = state === 'all' ? '' : `&state=${state}` + const { stdout } = await glabExecFileAsync( + [ + 'api', + `projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}` + ], + { cwd: repoPath } + ) + const data = JSON.parse(stdout) as Parameters[0][] + return { + items: data.map((d) => mapIssueToWorkItem(d, projectRef.path)), + 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): Promise { + await acquire() + try { + // Why: per_page=50 keeps the first-page round-trip small. Pagination + // is left for a follow-up — most users have <50 pending todos in + // practice and the UI shows the highest-priority ones first. + const { stdout } = await glabExecFileAsync( + ['api', '--paginate', 'todos?state=pending&per_page=50'], + { cwd: repoPath } + ) + 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 + } + // Why: --paginate concatenates JSON arrays (one per page) into a + // single stream. glab's behavior is to emit them as one JSON array + // when the endpoint returns arrays — we trust that contract here. + 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, + fn: (projectRef: ProjectRef, repoFlag: string) => Promise, + fallback: T +): Promise { + const knownHosts = await getGlabKnownHosts() + const projectRef = await getProjectRef(repoPath, knownHosts) + if (!projectRef) { + return fallback + } + return fn(projectRef, projectRef.path) +} + +export async function closeMR( + repoPath: string, + iid: number +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + async (_pr, repoFlag) => { + await acquire() + try { + await glabExecFileAsync(['mr', 'close', String(iid), '-R', repoFlag], { cwd: repoPath }) + 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' } + ) +} + +export async function reopenMR( + repoPath: string, + iid: number +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + async (_pr, repoFlag) => { + await acquire() + try { + await glabExecFileAsync(['mr', 'reopen', String(iid), '-R', repoFlag], { cwd: repoPath }) + 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' } + ) +} + +export async function mergeMR( + repoPath: string, + iid: number, + method: 'merge' | 'squash' | 'rebase' = 'merge' +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + async (_pr, 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], + { cwd: repoPath } + ) + 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' } + ) +} + +export async function addMRComment( + repoPath: string, + iid: number, + body: string +): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { + return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( + repoPath, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`, + '-f', + `body=${body}` + ], + { cwd: repoPath } + ) + 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' } + ) +} + +/** 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 } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts new file mode 100644 index 00000000000..96ab9a22b4e --- /dev/null +++ b/src/main/gitlab/gl-utils.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, glabExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + glabExecFileAsyncMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + glabExecFileAsync: glabExecFileAsyncMock +})) + +import { + _resetKnownHostsCache, + _resetProjectRefCache, + classifyGlabError, + classifyListIssuesError, + getIssueProjectRef, + getGlabKnownHosts, + getProjectRef, + parseGitLabProjectRef, + parseGlabApiResponse, + parseGlabAuthStatusHosts, + resolveIssueSource +} from './gl-utils' + +describe('gitlab project ref parsing', () => { + it('parses HTTPS and SSH GitLab.com remotes', () => { + expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({ + host: 'gitlab.com', + path: 'stablyai/orca' + }) + }) + + it('preserves nested group paths', () => { + expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({ + host: 'gitlab.com', + path: 'group/subgroup/project' + }) + expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({ + host: 'gitlab.com', + path: 'g1/g2/g3/proj' + }) + }) + + it('returns null for non-GitLab hosts when host not in knownHosts', () => { + expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull() + expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull() + }) + + it('matches self-hosted hosts when included in knownHosts', () => { + expect( + parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [ + 'gitlab.com', + 'gitlab.example.com' + ]) + ).toEqual({ host: 'gitlab.example.com', path: 'team/api' }) + }) + + it('rejects single-segment paths (host root or user-only)', () => { + expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull() + expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull() + }) + + it('handles missing .git suffix', () => { + expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({ + host: 'gitlab.com', + path: 'acme/widgets' + }) + }) +}) + +describe('gitlab project ref resolution', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + _resetProjectRefCache() + }) + + it('keeps getProjectRef origin-based', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@gitlab.com:fork/orca.git\n' + }) + + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + }) + + it('prefers upstream for issue project ref resolution', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@gitlab.com:stablyai/orca.git\n' + }) + + await expect(getIssueProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'stablyai/orca' + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], { + cwd: '/repo' + }) + }) + + it('falls back to origin when upstream is missing or non-GitLab', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git@example.com:stablyai/orca.git\n' }) + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:fork/orca.git\n' }) + + await expect(getIssueProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + }) + + it('does not mix origin and upstream cache entries for the same repo path', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:fork/orca.git\n' }) + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:stablyai/orca.git\n' }) + + await expect(getProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'fork/orca' + }) + await expect(getIssueProjectRef('/repo')).resolves.toEqual({ + host: 'gitlab.com', + path: 'stablyai/orca' + }) + }) +}) + +describe('resolveIssueSource', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + _resetProjectRefCache() + }) + + it("'auto' + upstream exists → upstream, fellBack=false", async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@gitlab.com:stablyai/orca.git\n' + }) + + await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({ + source: { host: 'gitlab.com', path: 'stablyai/orca' }, + fellBack: false + }) + }) + + it("'auto' + no upstream → origin, fellBack=false", async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git@example.com:stablyai/orca.git\n' }) + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:solo/orca.git\n' }) + + await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({ + source: { host: 'gitlab.com', path: 'solo/orca' }, + fellBack: false + }) + }) + + it("'upstream' + no upstream remote → origin, fellBack=true", async () => { + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('fatal: No such remote')) + .mockResolvedValueOnce({ stdout: 'git@gitlab.com:solo/orca.git\n' }) + + await expect(resolveIssueSource('/repo', 'upstream')).resolves.toEqual({ + source: { host: 'gitlab.com', path: 'solo/orca' }, + fellBack: true + }) + }) + + it("'origin' + upstream exists → origin (ignores upstream), fellBack=false", async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@gitlab.com:fork/orca.git\n' + }) + + await expect(resolveIssueSource('/repo', 'origin')).resolves.toEqual({ + source: { host: 'gitlab.com', path: 'fork/orca' }, + fellBack: false + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { + cwd: '/repo' + }) + }) + + it('undefined preference is treated identically to auto', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@gitlab.com:stablyai/orca.git\n' + }) + + await expect(resolveIssueSource('/repo', undefined)).resolves.toEqual({ + source: { host: 'gitlab.com', path: 'stablyai/orca' }, + fellBack: false + }) + }) +}) + +describe('glab error classification', () => { + it('classifies 403/forbidden as permission_denied', () => { + expect(classifyGlabError('HTTP 403 Forbidden').type).toBe('permission_denied') + expect(classifyGlabError('insufficient_scope').type).toBe('permission_denied') + }) + + it('classifies 404 / project not found as not_found', () => { + expect(classifyGlabError('HTTP 404 Not Found').type).toBe('not_found') + expect(classifyGlabError('Project Not Found').type).toBe('not_found') + }) + + it('classifies 422 / unprocessable as validation_error', () => { + expect(classifyGlabError('HTTP 422 Unprocessable Entity').type).toBe('validation_error') + }) + + it('classifies rate-limit signals as rate_limited', () => { + expect(classifyGlabError('HTTP 429 Too Many Requests').type).toBe('rate_limited') + expect(classifyGlabError('rate limit exceeded').type).toBe('rate_limited') + }) + + it('classifies timeout / dns / network as network_error', () => { + expect(classifyGlabError('connection timeout').type).toBe('network_error') + expect(classifyGlabError('could not resolve host: gitlab.com').type).toBe('network_error') + expect(classifyGlabError('network unreachable').type).toBe('network_error') + }) + + it('falls back to unknown for unrecognized stderr', () => { + expect(classifyGlabError('something weird happened').type).toBe('unknown') + }) + + it('rewrites copy for read contexts via classifyListIssuesError', () => { + expect(classifyListIssuesError('HTTP 403').message).toMatch(/permission to read issues/i) + expect(classifyListIssuesError('HTTP 404').message).toBe('Project not found.') + }) +}) + +describe('glab auth status host parsing', () => { + it('extracts hosts from "Logged in to " lines', () => { + const out = ` +✓ Logged in to gitlab.com as user1 (oauth2) +✓ Logged in to gitlab.example.com as user2 (token) + ` + expect(parseGlabAuthStatusHosts(out).sort()).toEqual(['gitlab.com', 'gitlab.example.com']) + }) + + it('extracts hosts from header-style lines', () => { + const out = ` +gitlab.example.com: + Logged in as user2 + ` + expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com') + }) + + it('returns empty list for output with no hosts', () => { + expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([]) + }) +}) + +describe('parseGlabApiResponse', () => { + it('splits headers and body at the first blank line (LF)', () => { + const stdout = 'HTTP/2.0 200 OK\nX-Total: 42\nX-Total-Pages: 3\n\n[{"iid":1}]' + const parsed = parseGlabApiResponse(stdout) + expect(parsed.headers).toEqual({ 'x-total': '42', 'x-total-pages': '3' }) + expect(parsed.body).toBe('[{"iid":1}]') + }) + + it('handles CRLF line endings', () => { + const stdout = 'HTTP/2.0 200 OK\r\nX-Total: 7\r\n\r\n[]' + const parsed = parseGlabApiResponse(stdout) + expect(parsed.headers['x-total']).toBe('7') + expect(parsed.body).toBe('[]') + }) + + it('lowercases header names for stable lookup', () => { + const stdout = 'HTTP/2.0 200 OK\nX-Total: 1\nContent-Type: application/json\n\n{}' + const parsed = parseGlabApiResponse(stdout) + expect(parsed.headers['x-total']).toBe('1') + expect(parsed.headers['content-type']).toBe('application/json') + }) + + it('returns the full input as body when there is no header separator', () => { + const stdout = '{"iid":1}' + const parsed = parseGlabApiResponse(stdout) + expect(parsed.body).toBe(stdout) + expect(parsed.headers).toEqual({}) + }) + + it('skips the status line in the header block', () => { + const stdout = 'HTTP/2.0 200 OK\nX-Total: 5\n\n[]' + const parsed = parseGlabApiResponse(stdout) + // The status line should not have leaked into headers under any key. + expect(parsed.headers['http/2.0']).toBeUndefined() + expect(parsed.headers['x-total']).toBe('5') + }) +}) + +describe('getGlabKnownHosts', () => { + beforeEach(() => { + glabExecFileAsyncMock.mockReset() + _resetKnownHostsCache() + }) + + it('returns gitlab.com plus auth-status hosts, deduped', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: '✓ Logged in to gitlab.com as user\n✓ Logged in to gitlab.example.com as user\n', + stderr: '' + }) + + await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com']) + }) + + it('falls back to default when glab auth status fails', async () => { + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated')) + + await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com']) + }) + + it('caches the result across calls', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: '✓ Logged in to gitlab.com as user\n', + stderr: '' + }) + + await getGlabKnownHosts() + await getGlabKnownHosts() + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts new file mode 100644 index 00000000000..cec1a1df225 --- /dev/null +++ b/src/main/gitlab/gl-utils.ts @@ -0,0 +1,315 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' +import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' +import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types' + +// Why: legacy generic execFile wrapper — only used by callers that don't need +// WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from +// the runner instead. +export const execFileAsync = promisify(execFile) +export { glabExecFileAsync, gitExecFileAsync } + +// ── Concurrency limiter — max 4 parallel glab processes ───────────── +// Why: parallel to gh-utils' limiter. Separate state from the gh limiter +// because gh and glab are independent binaries; one provider's spawns +// shouldn't throttle the other's. Cap matches gh-utils for consistency. +const MAX_CONCURRENT = 4 +let running = 0 +const queue: (() => void)[] = [] + +export function acquire(): Promise { + if (running < MAX_CONCURRENT) { + running++ + return Promise.resolve() + } + return new Promise((resolve) => + queue.push(() => { + running++ + resolve() + }) + ) +} + +export function release(): void { + running-- + const next = queue.shift() + if (next) { + next() + } +} + +// ── Error classification ───────────────────────────────────────────── +// Why: glab CLI surfaces API errors as unstructured stderr — same shape +// as gh. Map known GitLab patterns to typed errors so callers can show +// user-friendly messages. +export function classifyGlabError(stderr: string): ClassifiedError { + const s = stderr.toLowerCase() + if (s.includes('http 403') || s.includes('forbidden') || s.includes('insufficient_scope')) { + return { + type: 'permission_denied', + message: "You don't have permission to edit this issue. Check your GitLab token scopes." + } + } + if (s.includes('http 404') || s.includes('project not found')) { + return { type: 'not_found', message: 'Issue not found — it may have been deleted.' } + } + if (s.includes('http 422') || s.includes('unprocessable')) { + return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` } + } + // Why: GitLab returns 429 for rate limit; gh's "rate limit" stderr substring + // also fires through the user-mode token bucket. Cover both. + if (s.includes('rate limit') || s.includes('http 429')) { + return { + type: 'rate_limited', + message: 'GitLab rate limit hit. Try again in a few minutes.' + } + } + if ( + s.includes('timeout') || + s.includes('no such host') || + s.includes('network') || + s.includes('could not resolve host') + ) { + return { type: 'network_error', message: 'Network error — check your connection.' } + } + return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` } +} + +// Why: classifyGlabError's copy is phrased for edit/update operations; +// listIssues is a read op. Rewrite the message for read contexts while +// keeping the typed classification intact for callers/telemetry. +export function classifyListIssuesError(stderr: string): ClassifiedError { + const c = classifyGlabError(stderr) + const trimmed = stderr.trim() + // Exhaustive map so newly added error types surface as a TS error here + // rather than silently falling through to edit-phrased copy. + const readMessages: Record = { + permission_denied: + "You don't have permission to read issues for this project. Check your GitLab token scopes.", + not_found: 'Project not found.', + issues_disabled: 'Issues are disabled on this project.', + validation_error: `Invalid request — ${trimmed}`, + rate_limited: 'GitLab rate limit hit. Try again in a few minutes.', + network_error: 'Network error — check your connection.', + unknown: `Failed to load issues: ${trimmed}` + } + return { type: c.type, message: readMessages[c.type] } +} + +// ── Project ref resolution ────────────────────────────────────────── +// Why: alias the shared shape so `src/shared/types.ts#GitLabProjectRef` +// remains the single source of truth while main-side call sites can use +// the short local name `ProjectRef`. +export type ProjectRef = GitLabProjectRef + +const projectRefCache = new Map() + +/** @internal — exposed for tests only */ +export function _resetProjectRefCache(): void { + projectRefCache.clear() +} + +/** + * Hosts always treated as GitLab. Self-hosted instances are added at + * runtime via `getGlabKnownHosts()`, which inspects `glab auth status`. + */ +export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const + +export function parseGitLabProjectRef( + remoteUrl: string, + knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS +): ProjectRef | null { + const trimmed = remoteUrl.trim() + for (const host of knownHosts) { + const escapedHost = host.replace(/\./g, '\\.') + // Match SSH (git@host:path) and HTTPS (https://host/path) forms with an + // optional .git suffix. Path may contain nested groups — keep it whole. + const match = trimmed.match(new RegExp(`${escapedHost}[:/]([^\\s]+?)(?:\\.git)?$`)) + if (!match) { + continue + } + const path = match[1] + // Reject paths without at least one group segment — `gitlab.com:foo` + // alone is not a project reference. + if (!path.includes('/')) { + continue + } + return { host, path } + } + return null +} + +export async function getProjectRefForRemote( + repoPath: string, + remoteName: string, + knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS +): Promise { + const cacheKey = `${repoPath}\0${remoteName}\0${knownHosts.join(',')}` + if (projectRefCache.has(cacheKey)) { + return projectRefCache.get(cacheKey)! + } + try { + const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { + cwd: repoPath + }) + const result = parseGitLabProjectRef(stdout, knownHosts) + if (result) { + projectRefCache.set(cacheKey, result) + return result + } + } catch { + // ignore — non-GitLab remote or no remote configured + } + projectRefCache.set(cacheKey, null) + return null +} + +export async function getProjectRef( + repoPath: string, + knownHosts?: readonly string[] +): Promise { + return getProjectRefForRemote(repoPath, 'origin', knownHosts) +} + +export async function getIssueProjectRef( + repoPath: string, + knownHosts?: readonly string[] +): Promise { + const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts) + if (upstream) { + return upstream + } + return getProjectRefForRemote(repoPath, 'origin', knownHosts) +} + +export type ResolvedIssueSource = { + source: ProjectRef | null + /** True when the user preferred `upstream` but the upstream remote is no + * longer configured and the resolver fell back to origin. */ + fellBack: boolean +} + +/** + * Resolve the issue source for a repo honoring the user's per-repo + * preference. Mirrors `resolveIssueSource` in gh-utils — the upstream/ + * origin/auto semantics are git-remote concepts, not GitHub-specific. + */ +export async function resolveIssueSource( + repoPath: string, + preference: IssueSourcePreference | undefined, + knownHosts?: readonly string[] +): Promise { + if (preference === 'upstream') { + const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts) + if (upstream) { + return { source: upstream, fellBack: false } + } + const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts) + return { source: origin, fellBack: origin !== null } + } + if (preference === 'origin') { + return { + source: await getProjectRefForRemote(repoPath, 'origin', knownHosts), + fellBack: false + } + } + return { source: await getIssueProjectRef(repoPath, knownHosts), fellBack: false } +} + +// ── Known-hosts discovery via `glab auth status` ──────────────────── +// Why: glab supports multiple hosts (gitlab.com plus self-hosted). The +// authoritative list of "what counts as GitLab" from the user's POV is +// "what hosts have I authenticated with". Parse hostnames out of +// `glab auth status` output and cache the result process-wide. + +let knownHostsCache: readonly string[] | null = null + +/** @internal — exposed for tests only */ +export function _resetKnownHostsCache(): void { + knownHostsCache = null +} + +export async function getGlabKnownHosts(): Promise { + if (knownHostsCache) { + return knownHostsCache + } + try { + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) + // Why: glab writes auth status to stderr in some versions, stdout in + // others. Concatenate so the parser sees both. + const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) + // Always include gitlab.com so a fresh-install user with no auth + // still recognizes the canonical host. + const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) + knownHostsCache = merged + return merged + } catch { + // Auth check failed (glab not installed, no auth, etc.) — fall back + // to the canonical default. The caller will hit the auth error on + // the first real request anyway. + knownHostsCache = [...DEFAULT_GITLAB_HOSTS] + return knownHostsCache + } +} + +// ── Paginated `glab api -i` helper ────────────────────────────────── +// Why: GitLab returns total counts via response headers (X-Total, +// X-Total-Pages) on paginated REST endpoints. `glab api` discards +// headers by default; passing `-i` includes the raw HTTP response +// before the JSON body. Parse out the headers + body so callers can +// surface "Page X of Y" UIs without hand-rolling a second count call. + +export type GlabApiResponse = { + body: string + headers: Record +} + +export async function glabApiWithHeaders( + args: string[], + options?: { cwd?: string } +): Promise { + const { stdout } = await glabExecFileAsync(['api', '-i', ...args], options) + return parseGlabApiResponse(stdout) +} + +/** @internal — exported for tests. */ +export function parseGlabApiResponse(stdout: string): GlabApiResponse { + // Why: response is `HTTP/x.y status\nHeader: val\n…\n\n`. + // Match the first blank line (CRLF or LF) as the boundary. + const sepMatch = stdout.match(/\r?\n\r?\n/) + if (!sepMatch || sepMatch.index === undefined) { + return { body: stdout, headers: {} } + } + const headerBlock = stdout.slice(0, sepMatch.index) + const body = stdout.slice(sepMatch.index + sepMatch[0].length) + const headers: Record = {} + // Skip the status line (HTTP/x.y …) and parse the rest as key: value. + const lines = headerBlock.split(/\r?\n/) + for (const line of lines) { + const m = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/) + if (m) { + headers[m[1].toLowerCase()] = m[2].trim() + } + } + return { body, headers } +} + +// Why: glab auth status output is human-formatted and varies across versions. +// Two patterns observed in the wild: +// 1) "✓ Logged in to gitlab.com as " +// 2) "gitlab.example.com:" header followed by indented status lines +// Match both, dedupe, lowercase. Best-effort — anything that looks like a +// hostname. +export function parseGlabAuthStatusHosts(output: string): string[] { + const hosts = new Set() + for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) { + hosts.add(m[1].toLowerCase()) + } + for (const line of output.split('\n')) { + const m = line.match(/^([a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}):\s*$/) + if (m) { + hosts.add(m[1].toLowerCase()) + } + } + return Array.from(hosts) +} diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts new file mode 100644 index 00000000000..da3b3e61566 --- /dev/null +++ b/src/main/gitlab/issues.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GlUtils from './gl-utils' + +const { + glabExecFileAsyncMock, + getIssueProjectRefMock, + resolveIssueSourceMock, + getGlabKnownHostsMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + glabExecFileAsyncMock: vi.fn(), + getIssueProjectRefMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + getGlabKnownHostsMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gl-utils', async () => { + const actual = await vi.importActual('./gl-utils') + return { + ...actual, + glabExecFileAsync: glabExecFileAsyncMock, + getIssueProjectRef: getIssueProjectRefMock, + resolveIssueSource: resolveIssueSourceMock, + getGlabKnownHosts: getGlabKnownHostsMock, + acquire: acquireMock, + release: releaseMock + } +}) + +import { addIssueComment, createIssue, getIssue, listIssues, updateIssue } from './issues' + +describe('gitlab issue operations', () => { + beforeEach(() => { + glabExecFileAsyncMock.mockReset() + getIssueProjectRefMock.mockReset() + resolveIssueSourceMock.mockReset() + getGlabKnownHostsMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + getGlabKnownHostsMock.mockResolvedValue(['gitlab.com']) + resolveIssueSourceMock.mockImplementation(async () => ({ + source: await getIssueProjectRefMock(), + fellBack: false + })) + }) + + it('gets a single issue from the project ref', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 923, + title: 'Use upstream issues', + state: 'opened', + web_url: 'https://gitlab.com/stablyai/orca/-/issues/923', + labels: [] + }) + }) + + await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ number: 923 }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/stablyai%2Forca/issues/923'], + { cwd: '/repo-root' } + ) + }) + + it('encodes nested group paths', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ + host: 'gitlab.com', + path: 'group/subgroup/project' + }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ iid: 1, title: 't', state: 'opened' }) + }) + + await getIssue('/repo-root', 1) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/group%2Fsubgroup%2Fproject/issues/1'], + { cwd: '/repo-root' } + ) + }) + + it('lists issues with state=opened ordering', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [] }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + 'projects/stablyai%2Forca/issues?per_page=5&order_by=updated_at&sort=desc&state=opened' + ], + { cwd: '/repo-root' } + ) + }) + + it('surfaces a permission_denied error instead of collapsing to empty', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden')) + + const result = await listIssues('/repo-root', 5) + + expect(result.items).toEqual([]) + expect(result.error?.type).toBe('permission_denied') + }) + + it('creates an issue and returns its iid + web_url', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 924, + web_url: 'https://gitlab.com/stablyai/orca/-/issues/924' + }) + }) + + await expect(createIssue('/repo-root', 'New issue', 'Body')).resolves.toEqual({ + ok: true, + number: 924, + url: 'https://gitlab.com/stablyai/orca/-/issues/924' + }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'api', + '-X', + 'POST', + 'projects/stablyai%2Forca/issues', + '-f', + 'title=New issue', + '-f', + 'description=Body' + ], + { cwd: '/repo-root' } + ) + }) + + it('rejects createIssue with empty title', async () => { + await expect(createIssue('/repo-root', ' ', 'body')).resolves.toEqual({ + ok: false, + error: 'Title is required' + }) + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('updateIssue closes via `glab issue close` when state=closed', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + await expect(updateIssue('/repo-root', 5, { state: 'closed' })).resolves.toEqual({ ok: true }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'close', '5', '-R', 'stablyai/orca'], + { cwd: '/repo-root' } + ) + }) + + it("updateIssue treats 'already closed' as a no-op", async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('Issue is already closed')) + + await expect(updateIssue('/repo-root', 5, { state: 'closed' })).resolves.toEqual({ ok: true }) + }) + + it('updateIssue applies field edits via `glab issue update`', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + await expect( + updateIssue('/repo-root', 5, { + title: 'Renamed', + addLabels: ['bug'], + removeLabels: ['stale'], + addAssignees: ['alice'], + removeAssignees: ['bob'] + }) + ).resolves.toEqual({ ok: true }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'issue', + 'update', + '5', + '-R', + 'stablyai/orca', + '--title', + 'Renamed', + '--label', + 'bug', + '--unlabel', + 'stale', + '--assignee', + 'alice', + '--unassignee', + 'bob' + ], + { cwd: '/repo-root' } + ) + }) + + it('addIssueComment posts to /notes and maps the response', async () => { + getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + id: 100, + author: { username: 'alice', avatar_url: 'https://example.com/a.png' }, + body: 'Hello', + created_at: '2026-05-05T10:00:00Z' + }) + }) + + const result = await addIssueComment('/repo-root', 5, 'Hello') + expect(result).toEqual({ + ok: true, + comment: { + id: 100, + author: 'alice', + authorAvatarUrl: 'https://example.com/a.png', + body: 'Hello', + createdAt: '2026-05-05T10:00:00Z', + url: '', + isBot: false + } + }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', '-X', 'POST', 'projects/stablyai%2Forca/issues/5/notes', '-f', 'body=Hello'], + { cwd: '/repo-root' } + ) + }) + + it('returns null from getIssue when project ref cannot be resolved', async () => { + getIssueProjectRefMock.mockResolvedValueOnce(null) + // Why: when there's no GitLab project ref the fallback path + // (`glab issue view` from cwd) runs — simulate a glab failure to ensure + // we surface null cleanly. + glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not a glab repo')) + + await expect(getIssue('/repo-root', 1)).resolves.toBeNull() + }) + + it('updateIssue returns error when project ref cannot be resolved', async () => { + getIssueProjectRefMock.mockResolvedValueOnce(null) + await expect(updateIssue('/repo-root', 5, { state: 'closed' })).resolves.toEqual({ + ok: false, + error: 'Could not resolve GitLab project for this repository' + }) + }) +}) diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts new file mode 100644 index 00000000000..811c73de471 --- /dev/null +++ b/src/main/gitlab/issues.ts @@ -0,0 +1,414 @@ +/* eslint-disable max-lines -- Why: parallel to src/main/github/issues.ts — +co-locating issue list/create/update/comment operations keeps the shared +acquire/release + error-classification pattern obvious. Each function is +short; the file is long because the surface is broad. */ +import type { + ClassifiedError, + GitLabAssignableUser, + GitLabCommentResult, + GitLabIssueInfo, + GitLabIssueUpdate, + IssueSourcePreference, + MRComment +} from '../../shared/types' +import { mapGitLabIssueInfo } from './mappers' +// prettier-ignore +import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts } from './gl-utils' + +// Why: parallel to GitHub's IssueListResult — distinguishes a successful- +// empty listing from a failed fetch. +export type IssueListResult = { + items: GitLabIssueInfo[] + error?: ClassifiedError +} + +// Why: GitLab REST API addresses projects by URL-encoded path. Centralize +// the encoding so a future call site can't forget it (the slash escapes +// are easy to miss). +function encodedProject(projectPath: string): string { + return encodeURIComponent(projectPath) +} + +/** + * Get a single issue by number. + * + * Why this path doesn't take a preference — mirrors the GitHub issues.ts + * commentary: linked-issue lookups persist a number to a worktree at + * creation time. Routing detail lookups through the live per-repo + * preference would silently flip an existing link to a different project + * after the user toggled the selector. + */ +export async function getIssue( + repoPath: string, + issueNumber: number +): Promise { + const knownHosts = await getGlabKnownHosts() + const projectRef = await getIssueProjectRef(repoPath, knownHosts) + await acquire() + try { + if (projectRef) { + const { stdout } = await glabExecFileAsync( + ['api', `projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`], + { cwd: repoPath } + ) + const data = JSON.parse(stdout) + return mapGitLabIssueInfo(data) + } + // Fallback for non-GitLab remotes — let glab infer the project from cwd. + const { stdout } = await glabExecFileAsync( + ['issue', 'view', String(issueNumber), '--output', 'json'], + { cwd: repoPath } + ) + const data = JSON.parse(stdout) + return mapGitLabIssueInfo(data) + } catch { + return null + } finally { + release() + } +} + +/** + * List issues for a project. + * + * Mirrors github/listIssues — returns a structured IssueListResult so + * permission errors surface in the UI instead of collapsing to "No issues". + */ +// Why: GitLab issues only have 'opened' / 'closed' lifecycle states. +// 'all' maps to no state param so the API returns both. +export type IssueListState = 'opened' | 'closed' | 'all' + +export async function listIssues( + repoPath: string, + limit = 20, + preference?: IssueSourcePreference, + state: IssueListState = 'opened' +): Promise { + const knownHosts = await getGlabKnownHosts() + const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + await acquire() + try { + if (projectRef) { + const stateParam = state === 'all' ? '' : `&state=${state}` + const { stdout } = await glabExecFileAsync( + [ + 'api', + `projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}` + ], + { cwd: repoPath } + ) + const data = JSON.parse(stdout) as Record[] + // Why: GitLab's project issues endpoint returns true issues only + // (MRs are a separate endpoint), so no equivalent of GitHub's + // pull_request filter is needed here. + return { + items: data.map((d) => mapGitLabIssueInfo(d as Parameters[0])) + } + } + // Fallback — let glab infer project from cwd. The CLI flag for + // state varies per glab version (--opened, --closed, --all); + // pass through only when targeting a specific state. + const stateFlag = state === 'closed' ? ['--closed'] : state === 'all' ? ['--all'] : ['--opened'] + const { stdout } = await glabExecFileAsync( + ['issue', 'list', '--output', 'json', '--per-page', String(limit), ...stateFlag], + { cwd: repoPath } + ) + const data = JSON.parse(stdout) as unknown[] + return { + items: data.map((d) => mapGitLabIssueInfo(d as Parameters[0])) + } + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err) + return { + items: [], + error: classifyListIssuesError(stderr) + } + } finally { + release() + } +} + +/** + * Create a new GitLab issue. Uses `glab api` with explicit project path so + * the call doesn't depend on cwd matching the project the user picked. + */ +export async function createIssue( + repoPath: string, + title: string, + body: string, + preference?: IssueSourcePreference +): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> { + const trimmedTitle = title.trim() + if (!trimmedTitle) { + return { ok: false, error: 'Title is required' } + } + const knownHosts = await getGlabKnownHosts() + const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + if (!projectRef) { + return { + ok: false, + error: 'Could not resolve GitLab project for this repository' + } + } + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/issues`, + '-f', + `title=${trimmedTitle}`, + '-f', + // Why: GitLab uses `description` (not `body`) for issue text. + `description=${body}` + ], + { cwd: repoPath } + ) + const data = JSON.parse(stdout) as { iid?: number; web_url?: string; url?: string } + if (typeof data.iid !== 'number') { + return { ok: false, error: 'Unexpected response from GitLab' } + } + return { + ok: true, + number: data.iid, + url: String(data.web_url ?? data.url ?? '') + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } finally { + release() + } +} + +/** + * Update an existing GitLab issue. + * + * Why this path doesn't take a preference — mirrors github/updateIssue: + * mutations target an issue number already bound to a worktree / linked + * elsewhere. Routing through the live per-repo preference would let a + * user open upstream#N, toggle selector to origin, save, and silently + * write to a different project's issue with the same iid. + */ +export async function updateIssue( + repoPath: string, + issueNumber: number, + updates: GitLabIssueUpdate +): Promise<{ ok: true } | { ok: false; error: string }> { + const knownHosts = await getGlabKnownHosts() + const projectRef = await getIssueProjectRef(repoPath, knownHosts) + if (!projectRef) { + return { + ok: false, + error: 'Could not resolve GitLab project for this repository' + } + } + + const repoFlag = projectRef.path + const errors: string[] = [] + + // State change requires a separate command (parallel to github's split). + if (updates.state) { + await acquire() + try { + const cmd = updates.state === 'closed' ? 'close' : 'reopen' + await glabExecFileAsync(['issue', cmd, String(issueNumber), '-R', repoFlag], { + cwd: repoPath + }) + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err) + // Treat "already closed/reopened" as a no-op (matches gh path). + if (!stderr.toLowerCase().includes('already')) { + errors.push(classifyGlabError(stderr).message) + } + } finally { + release() + } + } + + // Field edits via `glab issue update`. + const editArgs: string[] = ['issue', 'update', String(issueNumber), '-R', repoFlag] + let hasEditArgs = false + + if (updates.title) { + editArgs.push('--title', updates.title) + hasEditArgs = true + } + for (const label of updates.addLabels ?? []) { + editArgs.push('--label', label) + hasEditArgs = true + } + for (const label of updates.removeLabels ?? []) { + editArgs.push('--unlabel', label) + hasEditArgs = true + } + for (const assignee of updates.addAssignees ?? []) { + editArgs.push('--assignee', assignee) + hasEditArgs = true + } + for (const assignee of updates.removeAssignees ?? []) { + editArgs.push('--unassignee', assignee) + hasEditArgs = true + } + + if (hasEditArgs) { + await acquire() + try { + await glabExecFileAsync(editArgs, { cwd: repoPath }) + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err) + errors.push(classifyGlabError(stderr).message) + } finally { + release() + } + } + + if (errors.length > 0) { + return { ok: false, error: errors.join('; ') } + } + return { ok: true } +} + +/** + * Add a comment (note) to an existing GitLab issue. Mirrors + * github/addIssueComment. + */ +export async function addIssueComment( + repoPath: string, + issueNumber: number, + body: string +): Promise { + const knownHosts = await getGlabKnownHosts() + const projectRef = await getIssueProjectRef(repoPath, knownHosts) + if (!projectRef) { + return { + ok: false, + error: 'Could not resolve GitLab project for this repository' + } + } + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/issues/${issueNumber}/notes`, + '-f', + `body=${body}` + ], + { cwd: repoPath } + ) + const data = JSON.parse(stdout) as { + id?: number + author?: { username?: string; avatar_url?: string; state?: string } | null + body?: string + created_at?: string + // Why: GitLab note responses don't include a per-note web_url; build one + // from the issue URL. We don't have the issue URL here, so leave blank + // — the renderer falls back to the issue URL when comment.url is empty. + } + const comment: MRComment = { + 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' + } + return { ok: true, comment } + } catch (err) { + const stderr = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(stderr).message } + } finally { + release() + } +} + +export async function listLabels( + repoPath: string, + preference?: IssueSourcePreference +): Promise { + const knownHosts = await getGlabKnownHosts() + const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + if (!projectRef) { + return [] + } + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + '--paginate', + `projects/${encodedProject(projectRef.path)}/labels`, + '--jq', + '.[].name' + ], + { cwd: repoPath } + ) + return stdout + .trim() + .split('\n') + .filter((l) => l.length > 0) + } catch { + return [] + } finally { + release() + } +} + +export async function listAssignableUsers( + repoPath: string, + preference?: IssueSourcePreference +): Promise { + const knownHosts = await getGlabKnownHosts() + const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts) + if (!projectRef) { + return [] + } + await acquire() + try { + // Why: `members/all` returns project members including those inherited + // from parent groups — important for projects under a top-level group + // where assignable users typically come from the group, not the project. + // --paginate walks every page; --jq emits NDJSON. + const { stdout } = await glabExecFileAsync( + [ + 'api', + '--paginate', + `projects/${encodedProject(projectRef.path)}/members/all?per_page=100`, + '--jq', + '.[] | {username, name, avatar_url}' + ], + { cwd: repoPath } + ) + type RESTMember = { username?: string; name?: string | null; avatar_url?: string | null } + const users: GitLabAssignableUser[] = [] + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed) { + continue + } + try { + const user = JSON.parse(trimmed) as RESTMember + if (user.username) { + users.push({ + username: user.username, + name: user.name ?? null, + avatarUrl: user.avatar_url ?? '' + }) + } + } catch { + // Skip malformed NDJSON lines defensively. + } + } + return users + } catch { + return [] + } finally { + release() + } +} diff --git a/src/main/gitlab/mappers-workitem.test.ts b/src/main/gitlab/mappers-workitem.test.ts new file mode 100644 index 00000000000..1c5fe731373 --- /dev/null +++ b/src/main/gitlab/mappers-workitem.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers' + +describe('mapMRToWorkItem', () => { + it('produces a unified GitLabWorkItem with branch + author', () => { + expect( + mapMRToWorkItem( + { + id: 100, + iid: 5, + title: 'Add support', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/merge_requests/5', + updated_at: '2026-05-05T10:00:00Z', + source_branch: 'feat-x', + target_branch: 'main', + author: { username: 'alice' }, + source_project_id: 7, + target_project_id: 7, + labels: [{ name: 'bug' }, 'p1'] + }, + 'g/p' + ) + ).toEqual({ + id: 'gitlab-mr-100', + type: 'mr', + number: 5, + title: 'Add support', + state: 'opened', + url: 'https://gitlab.com/g/p/-/merge_requests/5', + labels: ['bug', 'p1'], + updatedAt: '2026-05-05T10:00:00Z', + author: 'alice', + branchName: 'feat-x', + baseRefName: 'main', + isCrossRepository: false, + repoId: 'g/p' + }) + }) + + it('flags cross-repository when source_project_id !== target_project_id', () => { + const item = mapMRToWorkItem( + { + iid: 1, + title: 't', + state: 'opened', + source_project_id: 5, + target_project_id: 7 + }, + 'g/p' + ) + expect(item.isCrossRepository).toBe(true) + }) + + it('does not flag cross-repository when project ids are absent', () => { + const item = mapMRToWorkItem({ iid: 1, title: 't', state: 'opened' }, 'g/p') + expect(item.isCrossRepository).toBe(false) + }) + + it('infers draft from a Draft: title prefix', () => { + const item = mapMRToWorkItem({ iid: 1, title: 'Draft: WIP refactor', state: 'opened' }, 'g/p') + expect(item.state).toBe('draft') + }) + + it('falls back to a deterministic id when GitLab omits global id', () => { + // Why: the GitLab list endpoint always returns id, but the per-MR + // detail endpoint occasionally omits it on older instances. The + // fallback keeps unique-per-(repo,iid) without colliding with other + // MRs in the picker. + const item = mapMRToWorkItem({ iid: 5, title: 't', state: 'opened' }, 'g/p') + expect(item.id).toBe('gitlab-mr-g/p-5') + }) +}) + +describe('mapIssueToWorkItem', () => { + it('coerces opened/closed and produces a unified GitLabWorkItem', () => { + expect( + mapIssueToWorkItem( + { + id: 200, + iid: 9, + title: 'bug', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/issues/9', + updated_at: '2026-05-05T10:00:00Z', + author: { username: 'alice' }, + labels: ['bug'] + }, + 'g/p' + ) + ).toEqual({ + id: 'gitlab-issue-200', + type: 'issue', + number: 9, + title: 'bug', + state: 'opened', + url: 'https://gitlab.com/g/p/-/issues/9', + labels: ['bug'], + updatedAt: '2026-05-05T10:00:00Z', + author: 'alice', + repoId: 'g/p' + }) + }) + + it("collapses any non-'opened' state to 'closed'", () => { + expect(mapIssueToWorkItem({ iid: 1, title: 't', state: 'closed' }, 'g/p').state).toBe('closed') + // Defensive: a future state we don't recognize must not leak + // through as a 'merged' or 'draft' value. + expect(mapIssueToWorkItem({ iid: 1, title: 't', state: 'weird' }, 'g/p').state).toBe('closed') + }) +}) diff --git a/src/main/gitlab/mappers.test.ts b/src/main/gitlab/mappers.test.ts new file mode 100644 index 00000000000..91fd9b62b09 --- /dev/null +++ b/src/main/gitlab/mappers.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest' +import { + derivePipelineStatus, + mapGitLabIssueInfo, + mapMRInfo, + mapMRState, + mapPipelineJobStatusToCheckStatus, + mapPipelineJobStatusToConclusion +} from './mappers' + +describe('mapPipelineJobStatusToCheckStatus', () => { + it('classifies queued lifecycle states', () => { + expect(mapPipelineJobStatusToCheckStatus('created')).toBe('queued') + expect(mapPipelineJobStatusToCheckStatus('pending')).toBe('queued') + expect(mapPipelineJobStatusToCheckStatus('waiting_for_resource')).toBe('queued') + expect(mapPipelineJobStatusToCheckStatus('preparing')).toBe('queued') + }) + + it('classifies running as in_progress', () => { + expect(mapPipelineJobStatusToCheckStatus('running')).toBe('in_progress') + }) + + it('classifies success/failed/canceled/skipped/manual as completed', () => { + expect(mapPipelineJobStatusToCheckStatus('success')).toBe('completed') + expect(mapPipelineJobStatusToCheckStatus('failed')).toBe('completed') + expect(mapPipelineJobStatusToCheckStatus('canceled')).toBe('completed') + expect(mapPipelineJobStatusToCheckStatus('skipped')).toBe('completed') + expect(mapPipelineJobStatusToCheckStatus('manual')).toBe('completed') + }) +}) + +describe('mapPipelineJobStatusToConclusion', () => { + it('maps terminal outcomes', () => { + expect(mapPipelineJobStatusToConclusion('success')).toBe('success') + expect(mapPipelineJobStatusToConclusion('failed')).toBe('failure') + expect(mapPipelineJobStatusToConclusion('canceled')).toBe('cancelled') + expect(mapPipelineJobStatusToConclusion('canceling')).toBe('cancelled') + expect(mapPipelineJobStatusToConclusion('skipped')).toBe('skipped') + }) + + it("maps 'manual' to neutral so it doesn't stall pending forever", () => { + expect(mapPipelineJobStatusToConclusion('manual')).toBe('neutral') + }) + + it('maps active lifecycle states to pending', () => { + expect(mapPipelineJobStatusToConclusion('running')).toBe('pending') + expect(mapPipelineJobStatusToConclusion('pending')).toBe('pending') + expect(mapPipelineJobStatusToConclusion('scheduled')).toBe('pending') + }) + + it('returns null for unknown', () => { + expect(mapPipelineJobStatusToConclusion('weird-status')).toBeNull() + }) +}) + +describe('mapMRState', () => { + it('maps merged/closed/locked directly', () => { + expect(mapMRState('merged')).toBe('merged') + expect(mapMRState('closed')).toBe('closed') + expect(mapMRState('locked')).toBe('locked') + }) + + it('returns draft when the draft flag is set', () => { + expect(mapMRState('opened', true)).toBe('draft') + }) + + it("infers draft from a 'Draft:' title prefix", () => { + expect(mapMRState('opened', false, 'Draft: refactor auth')).toBe('draft') + expect(mapMRState('opened', undefined, 'WIP: in progress')).toBe('draft') + }) + + it("returns 'opened' for plain open MRs", () => { + expect(mapMRState('opened', false, 'Add gitlab support')).toBe('opened') + expect(mapMRState('opened')).toBe('opened') + }) +}) + +describe('mapGitLabIssueInfo', () => { + it('uses iid as the number when present', () => { + expect( + mapGitLabIssueInfo({ + iid: 42, + title: 'A', + state: 'opened', + web_url: 'https://gitlab.com/g/p/-/issues/42', + labels: [{ name: 'bug' }, { name: 'p1' }] + }) + ).toEqual({ + number: 42, + title: 'A', + state: 'opened', + url: 'https://gitlab.com/g/p/-/issues/42', + labels: ['bug', 'p1'] + }) + }) + + it('falls back to number when iid is absent', () => { + expect(mapGitLabIssueInfo({ number: 7, title: 'B', state: 'closed' })).toEqual({ + number: 7, + title: 'B', + state: 'closed', + url: '', + labels: [] + }) + }) + + it('handles string-only labels', () => { + expect(mapGitLabIssueInfo({ iid: 1, title: 'C', state: 'opened', labels: ['bug'] })).toEqual({ + number: 1, + title: 'C', + state: 'opened', + url: '', + labels: ['bug'] + }) + }) + + it('passes description / author / authorAvatarUrl through when present', () => { + const info = mapGitLabIssueInfo({ + iid: 9, + title: 'bug', + state: 'opened', + description: 'Steps to reproduce.', + author: { username: 'bob', avatar_url: 'https://example.com/b.png' } + }) + expect(info.description).toBe('Steps to reproduce.') + expect(info.author).toBe('bob') + expect(info.authorAvatarUrl).toBe('https://example.com/b.png') + }) +}) + +describe('mapMRInfo', () => { + it('builds an MRInfo from a typical glab payload', () => { + expect( + mapMRInfo( + { + iid: 10, + title: 'Add gitlab support', + state: 'opened', + draft: false, + web_url: 'https://gitlab.com/g/p/-/merge_requests/10', + updated_at: '2026-05-05T10:00:00Z', + sha: 'deadbeef', + has_conflicts: false, + detailed_merge_status: 'mergeable' + }, + 'success' + ) + ).toEqual({ + number: 10, + title: 'Add gitlab support', + state: 'opened', + url: 'https://gitlab.com/g/p/-/merge_requests/10', + pipelineStatus: 'success', + updatedAt: '2026-05-05T10:00:00Z', + mergeable: 'MERGEABLE', + headSha: 'deadbeef' + }) + }) + + it('marks CONFLICTING when has_conflicts is true', () => { + const info = mapMRInfo( + { + iid: 1, + title: 't', + state: 'opened', + has_conflicts: true, + detailed_merge_status: 'mergeable' + }, + 'pending' + ) + expect(info.mergeable).toBe('CONFLICTING') + }) + + it('marks UNKNOWN when detailed_merge_status is non-mergeable but not a conflict', () => { + const info = mapMRInfo( + { iid: 1, title: 't', state: 'opened', detailed_merge_status: 'checking' }, + 'pending' + ) + expect(info.mergeable).toBe('UNKNOWN') + }) + + it('returns draft state when draft flag is set', () => { + const info = mapMRInfo({ iid: 1, title: 't', state: 'opened', draft: true }, 'neutral') + expect(info.state).toBe('draft') + }) + + it('passes description / author / authorAvatarUrl through when present', () => { + const info = mapMRInfo( + { + iid: 5, + title: 't', + state: 'opened', + description: '## Body\n\nDetails here.', + author: { username: 'alice', avatar_url: 'https://example.com/a.png' } + }, + 'success' + ) + expect(info.description).toBe('## Body\n\nDetails here.') + expect(info.author).toBe('alice') + expect(info.authorAvatarUrl).toBe('https://example.com/a.png') + }) + + it('omits description / author when absent (distinguishes from list payloads)', () => { + // Why: detail vs list endpoints differ — a `description` of '' on the + // type would be ambiguous with "list payload that stripped the body". + // Prefer absent over default '' so callers can tell them apart. + const info = mapMRInfo({ iid: 5, title: 't', state: 'opened' }, 'success') + expect('description' in info).toBe(false) + expect('author' in info).toBe(false) + expect('authorAvatarUrl' in info).toBe(false) + }) +}) + +// Why: mapMRToWorkItem / mapIssueToWorkItem tests live in +// mappers-workitem.test.ts so this file stays under the oxlint +// max-lines budget. Same import surface, same describe-per-export +// shape — split is mechanical, not behavioral. + +describe('derivePipelineStatus', () => { + it('returns neutral for null/undefined/empty', () => { + expect(derivePipelineStatus(null)).toBe('neutral') + expect(derivePipelineStatus(undefined)).toBe('neutral') + expect(derivePipelineStatus([])).toBe('neutral') + }) + + it('classifies a top-level pipeline string', () => { + expect(derivePipelineStatus('success')).toBe('success') + expect(derivePipelineStatus('failed')).toBe('failure') + expect(derivePipelineStatus('running')).toBe('pending') + expect(derivePipelineStatus('manual')).toBe('neutral') + }) + + it('rolls up an array of jobs', () => { + expect(derivePipelineStatus([{ status: 'success' }, { status: 'success' }])).toBe('success') + expect(derivePipelineStatus([{ status: 'success' }, { status: 'failed' }])).toBe('failure') + expect(derivePipelineStatus([{ status: 'success' }, { status: 'running' }])).toBe('pending') + }) + + it('failure beats pending in the rollup', () => { + expect(derivePipelineStatus([{ status: 'failed' }, { status: 'running' }])).toBe('failure') + }) + + it('handles a single object with status', () => { + expect(derivePipelineStatus({ status: 'success' })).toBe('success') + }) +}) diff --git a/src/main/gitlab/mappers.ts b/src/main/gitlab/mappers.ts new file mode 100644 index 00000000000..1570c64ab8d --- /dev/null +++ b/src/main/gitlab/mappers.ts @@ -0,0 +1,327 @@ +import type { + CheckStatus, + GitLabIssueInfo, + GitLabWorkItem, + MRCheckDetail, + MRInfo, + MRState +} from '../../shared/types' + +// ── Pipeline job mapping (GitLab REST `/pipelines/:id/jobs`) ──────── +// Why: GitLab pipeline jobs roughly map to GitHub check-runs, but use a +// single `status` field that combines lifecycle + outcome. We split it +// into PRCheckDetail's status + conclusion shape so the renderer can +// share a row with the GitHub side. + +export function mapPipelineJobStatusToCheckStatus(status: string): MRCheckDetail['status'] { + const s = status?.toLowerCase() + if (s === 'created' || s === 'pending' || s === 'waiting_for_resource' || s === 'preparing') { + return 'queued' + } + if (s === 'running') { + return 'in_progress' + } + return 'completed' +} + +export function mapPipelineJobStatusToConclusion(status: string): MRCheckDetail['conclusion'] { + const s = status?.toLowerCase() + if (s === 'success') { + return 'success' + } + if (s === 'failed') { + return 'failure' + } + if (s === 'canceled' || s === 'canceling') { + return 'cancelled' + } + if (s === 'skipped') { + return 'skipped' + } + // Why: 'manual' jobs require user trigger and never auto-complete; we + // surface them as neutral rather than pending so they don't stall the + // top-level rollup at "pending" forever. + if (s === 'manual') { + return 'neutral' + } + if ( + s === 'created' || + s === 'pending' || + s === 'running' || + s === 'waiting_for_resource' || + s === 'preparing' || + s === 'scheduled' + ) { + return 'pending' + } + return null +} + +// ── MR state mapping ──────────────────────────────────────────────── +// Why: glab returns the API state directly. Apply the draft flag (or a +// `Draft:` title prefix, which is GitLab's title-based draft convention) +// so the UI sees a single discriminator. + +export function mapMRState(state: string, isDraft?: boolean, title?: string): MRState { + const s = state?.toLowerCase() + if (s === 'merged') { + return 'merged' + } + if (s === 'closed') { + return 'closed' + } + if (s === 'locked') { + return 'locked' + } + // Why: GitLab supports drafts via either a boolean field (newer API) or + // a `Draft:` / `WIP:` title prefix (legacy). Either signal counts. + if (isDraft || (title && /^(draft|wip):\s*/i.test(title))) { + return 'draft' + } + return 'opened' +} + +// ── Issue mapping ──────────────────────────────────────────────────── +// glab issue view returns: { iid, title, state, web_url, labels: [{name}] | string[] } +// `state` is already lowercase 'opened' | 'closed' so the mapping is +// mostly a normalization shim. + +export function mapGitLabIssueInfo(data: { + iid?: number + number?: number + title: string + state: string + web_url?: string + url?: string + labels?: { name: string }[] | string[] + description?: string | null + author?: { username?: string | null; avatar_url?: string | null } | null +}): GitLabIssueInfo { + // Why: glab CLI flips between exposing `iid` and `number` depending on + // command + --output flag combination. Accept both. + const number = data.iid ?? data.number ?? 0 + const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)) + return { + number, + title: data.title, + state: data.state?.toLowerCase() === 'opened' ? 'opened' : 'closed', + url: data.web_url ?? data.url ?? '', + labels, + // Why: same description / author optional plumbing as mapMRInfo — + // list payloads strip these so callers can tell "absent" from "blank". + ...(typeof data.description === 'string' ? { description: data.description } : {}), + ...(data.author?.username ? { author: data.author.username } : {}), + ...(data.author?.avatar_url ? { authorAvatarUrl: data.author.avatar_url } : {}) + } +} + +// ── MR info mapping ────────────────────────────────────────────────── +// Why: parallel to mapPRState's role for GitHub. glab returns iid + +// web_url + state + draft + sha + has_conflicts. + +type GitLabMRRaw = { + iid?: number + number?: number + title: string + state: string + draft?: boolean + web_url?: string + url?: string + updated_at?: string + updatedAt?: string + sha?: string + has_conflicts?: boolean + detailed_merge_status?: string + description?: string | null + author?: { username?: string | null; avatar_url?: string | null } | null +} + +export function mapMRInfo(data: GitLabMRRaw, pipelineStatus: CheckStatus): MRInfo { + return { + number: data.iid ?? data.number ?? 0, + title: data.title, + state: mapMRState(data.state, data.draft, data.title), + url: data.web_url ?? data.url ?? '', + pipelineStatus, + updatedAt: data.updated_at ?? data.updatedAt ?? '', + mergeable: deriveMergeable(data), + headSha: data.sha, + // Why: detail-endpoint payloads include `description`; list endpoints + // strip it. Pass through what's present rather than coercing missing + // values to '' so downstream UIs can distinguish "no body authored" + // from "this came from a list and the body is unknown". + ...(typeof data.description === 'string' ? { description: data.description } : {}), + ...(data.author?.username ? { author: data.author.username } : {}), + ...(data.author?.avatar_url ? { authorAvatarUrl: data.author.avatar_url } : {}) + } +} + +function deriveMergeable(data: GitLabMRRaw): MRInfo['mergeable'] { + if (data.has_conflicts === true) { + return 'CONFLICTING' + } + // Why: detailed_merge_status is GitLab's richest signal. Treat + // 'mergeable' as the only positive value — every other state + // (checking, ci_must_pass, draft_status, etc.) is an unknown from the + // user's POV because it may flip without warning. + if (data.detailed_merge_status === 'mergeable') { + return 'MERGEABLE' + } + if (data.detailed_merge_status === 'broken_status' || data.detailed_merge_status === 'conflict') { + return 'CONFLICTING' + } + return 'UNKNOWN' +} + +// ── Pipeline rollup (parallel to GitHub deriveCheckStatus) ────────── +// Why: GitLab returns a single pipeline `status` for the head commit; we +// can also receive an array of jobs and roll them up the same way the +// GitHub side does. Accept either shape. + +export function derivePipelineStatus( + rollup: { status?: string }[] | { status?: string } | string | null | undefined +): CheckStatus { + if (!rollup) { + return 'neutral' + } + if (typeof rollup === 'string') { + return classifyPipelineString(rollup) + } + if (!Array.isArray(rollup)) { + return classifyPipelineString(rollup.status ?? '') + } + if (rollup.length === 0) { + return 'neutral' + } + let hasFailure = false + let hasPending = false + for (const job of rollup) { + const s = job.status?.toLowerCase() + if (s === 'failed') { + hasFailure = true + } else if ( + s === 'created' || + s === 'pending' || + s === 'running' || + s === 'waiting_for_resource' || + s === 'preparing' || + s === 'scheduled' + ) { + hasPending = true + } + } + if (hasFailure) { + return 'failure' + } + if (hasPending) { + return 'pending' + } + return 'success' +} + +// ── Raw → GitLabWorkItem mapping ──────────────────────────────────── +// Why: list endpoints return MR / issue records; the picker consumes a +// unified GitLabWorkItem. Mirrors the GitHub side where MainWorkItem is +// produced from PR / issue REST + GraphQL responses. + +type GitLabMRRawForWorkItem = { + id?: number + iid?: number + title: string + state: string + draft?: boolean + web_url?: string + url?: string + updated_at?: string + source_branch?: string + target_branch?: string + author?: { username?: string | null } | null + labels?: ({ name: string } | string)[] + /** Why: source_project_id !== target_project_id signals a fork MR. + * GitLab list endpoints include both — the picker uses this flag the + * same way GitHub's isCrossRepository disables fork-MR start points + * when the workspace flow can't safely resolve the head. */ + source_project_id?: number + target_project_id?: number +} + +export function mapMRToWorkItem(data: GitLabMRRawForWorkItem, repoId: string): GitLabWorkItem { + const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)) + const number = data.iid ?? 0 + return { + // Why: id needs to be unique across providers in the picker. Prefix + // 'gitlab-mr-' so a GitHub PR #5 and a GitLab MR !5 don't collide. + id: `gitlab-mr-${data.id ?? `${repoId}-${number}`}`, + type: 'mr', + number, + title: data.title, + state: mapMRState(data.state, data.draft, data.title), + url: data.web_url ?? data.url ?? '', + labels, + updatedAt: data.updated_at ?? '', + author: data.author?.username ?? null, + branchName: data.source_branch, + baseRefName: data.target_branch, + isCrossRepository: + data.source_project_id !== undefined && + data.target_project_id !== undefined && + data.source_project_id !== data.target_project_id, + repoId + } +} + +type GitLabIssueRawForWorkItem = { + id?: number + iid?: number + title: string + state: string + web_url?: string + url?: string + updated_at?: string + author?: { username?: string | null } | null + labels?: ({ name: string } | string)[] +} + +export function mapIssueToWorkItem( + data: GitLabIssueRawForWorkItem, + repoId: string +): GitLabWorkItem { + const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)) + const number = data.iid ?? 0 + // Issues only ever resolve to 'opened' or 'closed' (issue state space is + // narrower than MRs); coerce defensively without inventing values. + const state = data.state?.toLowerCase() === 'opened' ? 'opened' : 'closed' + return { + id: `gitlab-issue-${data.id ?? `${repoId}-${number}`}`, + type: 'issue', + number, + title: data.title, + state, + url: data.web_url ?? data.url ?? '', + labels, + updatedAt: data.updated_at ?? '', + author: data.author?.username ?? null, + repoId + } +} + +function classifyPipelineString(status: string): CheckStatus { + const s = status.toLowerCase() + if (s === 'success') { + return 'success' + } + if (s === 'failed') { + return 'failure' + } + if ( + s === 'created' || + s === 'pending' || + s === 'running' || + s === 'waiting_for_resource' || + s === 'preparing' || + s === 'scheduled' + ) { + return 'pending' + } + return 'neutral' +} diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts new file mode 100644 index 00000000000..4994a621960 --- /dev/null +++ b/src/main/gitlab/work-item-details.ts @@ -0,0 +1,252 @@ +// Why: aggregated detail-fetch for GitLabItemDialog. Parallel of +// src/main/github/work-item-details.ts but scoped to v1 surface — +// description body, flattened discussion notes, MR pipeline jobs. +// Files / inline review-comment positioning / approvals are deferred. +import type { + GitLabPipelineJob, + GitLabWorkItem, + GitLabWorkItemDetails, + MRComment +} from '../../shared/types' +import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers' +import { + acquire, + getGlabKnownHosts, + getIssueProjectRef, + getProjectRef, + glabExecFileAsync, + release, + type ProjectRef +} from './gl-utils' + +function encodedProject(projectPath: string): string { + return encodeURIComponent(projectPath) +} + +// ── Discussion → MRComment flattening ────────────────────────────── +// GitLab returns discussions with nested notes; the dialog renders a +// flat conversation. We drop system notes ("X assigned the MR", auto- +// generated changelog entries) since they aren't user-authored content. + +type GitLabRawNote = { + id?: number + body?: string + author?: { username?: string | null; avatar_url?: string | null; state?: string } | null + created_at?: string + system?: boolean + resolvable?: boolean + resolved?: boolean + position?: { new_path?: string; new_line?: number; old_line?: number } | null +} + +type GitLabRawDiscussion = { + id?: string + individual_note?: boolean + notes?: GitLabRawNote[] +} + +function flattenDiscussions(discussions: GitLabRawDiscussion[]): MRComment[] { + const out: MRComment[] = [] + for (const discussion of discussions) { + const notes = discussion.notes ?? [] + for (const note of notes) { + if (note.system === true) { + // Why: skip GitLab's auto-generated activity entries — they + // would dominate a busy MR's conversation tab if rendered. + continue + } + out.push({ + id: note.id ?? 0, + author: note.author?.username ?? 'unknown', + authorAvatarUrl: note.author?.avatar_url ?? '', + body: note.body ?? '', + createdAt: note.created_at ?? '', + url: '', + isBot: note.author?.state === 'bot', + ...(discussion.id ? { threadId: discussion.id } : {}), + ...(note.resolvable === true ? { isResolved: note.resolved === true } : {}), + ...(note.position?.new_path ? { path: note.position.new_path } : {}), + ...(typeof note.position?.new_line === 'number' ? { line: note.position.new_line } : {}) + }) + } + } + // Why: oldest-first matches gitlab.com's conversation rendering and + // makes "what's new" intuitive when polling for updates later. + return out.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? '')) +} + +async function fetchDiscussions( + repoPath: string, + projectRef: ProjectRef, + type: 'issue' | 'mr', + iid: number +): Promise { + const resource = type === 'mr' ? 'merge_requests' : 'issues' + const { stdout } = await glabExecFileAsync( + [ + 'api', + '--paginate', + `projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100` + ], + { cwd: repoPath } + ) + return JSON.parse(stdout) as GitLabRawDiscussion[] +} + +// ── Pipeline jobs ────────────────────────────────────────────────── + +type GitLabRawJob = { + id?: number + name?: string + stage?: string + status?: string + web_url?: string + duration?: number | null +} + +function mapPipelineJob(raw: GitLabRawJob): GitLabPipelineJob { + return { + id: raw.id ?? 0, + name: raw.name ?? '', + stage: raw.stage ?? '', + status: raw.status ?? '', + webUrl: raw.web_url ?? '', + duration: typeof raw.duration === 'number' ? raw.duration : null + } +} + +async function fetchPipelineJobs( + repoPath: string, + projectRef: ProjectRef, + pipelineId: number +): Promise { + const { stdout } = await glabExecFileAsync( + [ + 'api', + '--paginate', + `projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=100` + ], + { cwd: repoPath } + ) + const data = JSON.parse(stdout) as GitLabRawJob[] + return data.map(mapPipelineJob) +} + +// ── Top-level aggregator ─────────────────────────────────────────── + +type GitLabRawIssue = Parameters[0] & { + description?: string | null + assignees?: { username?: string | null }[] | null +} + +type GitLabRawMR = Parameters[0] & { + description?: string | null + sha?: string + diff_refs?: { base_sha?: string; head_sha?: string; start_sha?: string } | null + head_pipeline?: { id?: number } | null +} + +/** + * Fetch full details for a GitLab MR or issue: the work item itself, + * description body, discussion notes flattened to MRComment[], and (for + * MRs only) per-job pipeline status. + * + * Returns null when the project ref can't be resolved or the item + * can't be loaded — callers render a "not found" / error state. + */ +export async function getWorkItemDetails( + repoPath: string, + iid: number, + type: 'issue' | 'mr' +): Promise { + const knownHosts = await getGlabKnownHosts() + // Why: issues honor the upstream/origin preference (issues live on + // upstream when a fork is checked out). MRs always target origin — + // the fork model puts MRs against the project the user pushes to. + const projectRef = + type === 'issue' + ? await getIssueProjectRef(repoPath, knownHosts) + : await getProjectRef(repoPath, knownHosts) + if (!projectRef) { + return null + } + await acquire() + try { + if (type === 'issue') { + return await fetchIssueDetails(repoPath, projectRef, iid) + } + return await fetchMRDetails(repoPath, projectRef, iid) + } catch { + return null + } finally { + release() + } +} + +async function fetchIssueDetails( + repoPath: string, + projectRef: ProjectRef, + iid: number +): Promise { + // Why: fan out the two reads. Issues don't have a pipeline so this + // pair covers everything the dialog renders. + const [issueRes, discussions] = await Promise.all([ + glabExecFileAsync(['api', `projects/${encodedProject(projectRef.path)}/issues/${iid}`], { + cwd: repoPath + }), + fetchDiscussions(repoPath, projectRef, 'issue', iid) + ]) + const issueRaw = JSON.parse(issueRes.stdout) as GitLabRawIssue + const item: Omit = (() => { + const full = mapIssueToWorkItem(issueRaw, projectRef.path) + // Why: omit repoId from the returned shape — the renderer stamps + // it from the dialog's caller (TaskPage / picker) so the main + // process doesn't need to know Orca's Repo.id. + const { repoId: _repoId, ...rest } = full + return rest + })() + return { + item, + body: issueRaw.description ?? '', + comments: flattenDiscussions(discussions), + assignees: (issueRaw.assignees ?? []) + .map((a) => a?.username) + .filter((u): u is string => typeof u === 'string') + } +} + +async function fetchMRDetails( + repoPath: string, + projectRef: ProjectRef, + iid: number +): Promise { + // Why: MR detail + discussions in parallel. The pipeline jobs fetch + // depends on `head_pipeline.id` from the MR payload, so it has to + // wait — but it's a single follow-up call rather than a serial chain. + const [mrRes, discussions] = await Promise.all([ + glabExecFileAsync( + ['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`], + { cwd: repoPath } + ), + fetchDiscussions(repoPath, projectRef, 'mr', iid) + ]) + const mrRaw = JSON.parse(mrRes.stdout) as GitLabRawMR + const item: Omit = (() => { + const full = mapMRToWorkItem(mrRaw, projectRef.path) + const { repoId: _repoId, ...rest } = full + return rest + })() + const pipelineId = mrRaw.head_pipeline?.id + const pipelineJobs = + typeof pipelineId === 'number' + ? await fetchPipelineJobs(repoPath, projectRef, pipelineId).catch(() => []) + : undefined + return { + item, + body: mrRaw.description ?? '', + comments: flattenDiscussions(discussions), + headSha: mrRaw.sha, + baseSha: mrRaw.diff_refs?.base_sha, + ...(pipelineJobs !== undefined ? { pipelineJobs } : {}) + } +} diff --git a/src/main/ipc/gitlab.ts b/src/main/ipc/gitlab.ts new file mode 100644 index 00000000000..a927a9e8137 --- /dev/null +++ b/src/main/ipc/gitlab.ts @@ -0,0 +1,245 @@ +/* eslint-disable max-lines -- Why: parallel to ipc/github.ts — keeping all +GitLab IPC handlers co-located keeps the repo-path validation pattern +reviewable as one surface. */ +import { ipcMain } from 'electron' +import { resolve } from 'path' +import type { GitLabIssueUpdate, Repo } from '../../shared/types' +import type { Store } from '../persistence' +import { + addIssueComment, + addMRComment, + closeMR, + createIssue, + getAuthenticatedViewer, + getIssue, + getMergeRequest, + getMergeRequestForBranch, + getProjectSlug, + getWorkItemByProjectRef, + listAssignableUsers, + listIssues, + listLabels, + listMergeRequests, + listTodos, + listWorkItems, + mergeMR, + reopenMR, + updateIssue +} from '../gitlab/client' +import { getWorkItemDetails } from '../gitlab/work-item-details' +import { computeNextGitLabRecents } from '../../shared/gitlab-projects' +import type { ProjectRef } from '../gitlab/gl-utils' + +// Why: mirror github.ts assertRegisteredRepo — main-process handlers +// must never operate on a path the user hasn't explicitly registered as +// a repo (filesystem-auth boundary). +function assertRegisteredRepo(repoPath: string, store: Store): Repo { + const resolvedRepoPath = resolve(repoPath) + const repo = store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath) + if (!repo) { + throw new Error('Access denied: unknown repository path') + } + return repo +} + +export function registerGitLabHandlers(store: Store): void { + ipcMain.handle('gitlab:viewer', async () => { + return getAuthenticatedViewer() + }) + + ipcMain.handle('gitlab:projectSlug', async (_event, args: { repoPath: string }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return getProjectSlug(repo.path) + }) + + ipcMain.handle( + 'gitlab:mrForBranch', + async (_event, args: { repoPath: string; branch: string; linkedMRIid?: number | null }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return getMergeRequestForBranch(repo.path, args.branch, args.linkedMRIid ?? null) + } + ) + + ipcMain.handle('gitlab:mr', async (_event, args: { repoPath: string; iid: number }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return getMergeRequest(repo.path, args.iid) + }) + + ipcMain.handle( + 'gitlab:listMRs', + async ( + _event, + args: { + repoPath: string + state?: 'opened' | 'merged' | 'closed' | 'all' + page?: number + perPage?: number + } + ) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return listMergeRequests( + repo.path, + args.state ?? 'opened', + args.page ?? 1, + args.perPage ?? 20 + ) + } + ) + + ipcMain.handle('gitlab:issue', async (_event, args: { repoPath: string; number: number }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return getIssue(repo.path, args.number) + }) + + ipcMain.handle( + 'gitlab:listIssues', + async (_event, args: { repoPath: string; limit?: number }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + const result = await listIssues(repo.path, args.limit ?? 20) + // Why: parallel to gh:listIssues which returns just items[]. The + // structured envelope is preserved for callers that need the + // classified error; bare-items consumers get the same shape. + return result.items + } + ) + + ipcMain.handle( + 'gitlab:createIssue', + async (_event, args: { repoPath: string; title: string; body: string }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return createIssue(repo.path, args.title, args.body) + } + ) + + ipcMain.handle( + 'gitlab:updateIssue', + async (_event, args: { repoPath: string; number: number; updates: GitLabIssueUpdate }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return updateIssue(repo.path, args.number, args.updates) + } + ) + + ipcMain.handle( + 'gitlab:addIssueComment', + async (_event, args: { repoPath: string; number: number; body: string }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return addIssueComment(repo.path, args.number, args.body) + } + ) + + ipcMain.handle('gitlab:listLabels', async (_event, args: { repoPath: string }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return listLabels(repo.path) + }) + + ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: { repoPath: string }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return listAssignableUsers(repo.path) + }) + + // Why: combined MR + issue list — Tasks screen and any future picker + // that wants a unified view. Centralizes the merge / sort logic so + // callers don't have to re-implement it. + ipcMain.handle( + 'gitlab:listWorkItems', + async ( + _event, + args: { + repoPath: string + state?: 'opened' | 'merged' | 'closed' | 'all' + page?: number + perPage?: number + } + ) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return listWorkItems(repo.path, args.state ?? 'opened', args.page ?? 1, args.perPage ?? 20) + } + ) + + // Why: aggregated dialog payload — body + discussions + pipeline jobs. + // Powers GitLabItemDialog's tabs. + ipcMain.handle( + 'gitlab:workItemDetails', + async (_event, args: { repoPath: string; iid: number; type: 'issue' | 'mr' }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return getWorkItemDetails(repo.path, args.iid, args.type) + } + ) + + ipcMain.handle('gitlab:closeMR', async (_event, args: { repoPath: string; iid: number }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return closeMR(repo.path, args.iid) + }) + + ipcMain.handle('gitlab:reopenMR', async (_event, args: { repoPath: string; iid: number }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return reopenMR(repo.path, args.iid) + }) + + ipcMain.handle( + 'gitlab:mergeMR', + async ( + _event, + args: { repoPath: string; iid: number; method?: 'merge' | 'squash' | 'rebase' } + ) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return mergeMR(repo.path, args.iid, args.method ?? 'merge') + } + ) + + ipcMain.handle( + 'gitlab:addMRComment', + async (_event, args: { repoPath: string; iid: number; body: string }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return addMRComment(repo.path, args.iid, args.body) + } + ) + + // Why: My Todos surface — cross-project, user-scoped. The repoPath is + // only used for the registered-repo guard; `glab api todos` doesn't + // care about cwd because the endpoint is user-scoped. + ipcMain.handle('gitlab:todos', async (_event, args: { repoPath: string }) => { + const repo = assertRegisteredRepo(args.repoPath, store) + return listTodos(repo.path) + }) + + // Why: paste-URL flow in the picker. The user pastes a GitLab URL that + // may target a project different from the local checkout's remote, so + // the call carries the parsed project path explicitly rather than + // resolving from cwd. + ipcMain.handle( + 'gitlab:workItemByPath', + async ( + _event, + args: { + repoPath: string + host: string + path: string + iid: number + type: 'issue' | 'mr' + } + ) => { + const repo = assertRegisteredRepo(args.repoPath, store) + const projectRef: ProjectRef = { host: args.host, path: args.path } + const result = await getWorkItemByProjectRef(repo.path, projectRef, args.iid, args.type) + // Why: only persist a recent entry when the lookup actually + // produced an item. A 404 / auth failure shouldn't pollute the + // user's recents list with project paths they can't read. + if (result) { + addGitLabProjectToRecent(store, args.host, args.path) + } + return result + } + ) +} + +function addGitLabProjectToRecent(store: Store, host: string, path: string): void { + const settings = store.getSettings() + const existing = settings.gitlabProjects ?? { pinned: [], recent: [] } + store.updateSettings({ + gitlabProjects: { + pinned: existing.pinned, + recent: computeNextGitLabRecents(existing.recent, host, path) + } + }) +} diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts new file mode 100644 index 00000000000..edd2fa2a5c8 --- /dev/null +++ b/src/main/ipc/hosted-review.ts @@ -0,0 +1,38 @@ +import { ipcMain } from 'electron' +import { resolve } from 'path' +import type { HostedReviewForBranchArgs } from '../../shared/hosted-review' +import type { Repo } from '../../shared/types' +import type { Store } from '../persistence' +import type { StatsCollector } from '../stats/collector' +import { getHostedReviewForBranch } from '../source-control/hosted-review' + +function assertRegisteredRepo(repoPath: string, store: Store): Repo { + const resolvedRepoPath = resolve(repoPath) + const repo = store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath) + if (!repo) { + throw new Error('Access denied: unknown repository path') + } + return repo +} + +export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void { + ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => { + const repo = assertRegisteredRepo(args.repoPath, store) + const review = await getHostedReviewForBranch({ + repoPath: repo.path, + branch: args.branch, + linkedGitHubPR: args.linkedGitHubPR ?? null, + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null + }) + if (review?.provider === 'github' && !stats.hasCountedPR(review.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: review.number, prUrl: review.url } + }) + } + return review + }) +} diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index bc2453aa438..d2d3327322f 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -1,13 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, execFileMock, execFileAsyncMock, hydrateShellPathMock, mergePathSegmentsMock } = - vi.hoisted(() => ({ - handleMock: vi.fn(), - execFileMock: vi.fn(), - execFileAsyncMock: vi.fn(), - hydrateShellPathMock: vi.fn(), - mergePathSegmentsMock: vi.fn() - })) +const { + handleMock, + execFileMock, + execFileAsyncMock, + hydrateShellPathMock, + mergePathSegmentsMock, + getBitbucketAuthStatusMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + execFileMock: vi.fn(), + execFileAsyncMock: vi.fn(), + hydrateShellPathMock: vi.fn(), + mergePathSegmentsMock: vi.fn(), + getBitbucketAuthStatusMock: vi.fn() +})) vi.mock('electron', () => ({ ipcMain: { @@ -30,6 +37,10 @@ vi.mock('../startup/hydrate-shell-path', () => ({ mergePathSegments: mergePathSegmentsMock })) +vi.mock('../bitbucket/client', () => ({ + getBitbucketAuthStatus: getBitbucketAuthStatusMock +})) + import { _resetPreflightCache, detectInstalledAgents, @@ -47,6 +58,12 @@ describe('preflight', () => { execFileAsyncMock.mockReset() hydrateShellPathMock.mockReset() mergePathSegmentsMock.mockReset() + getBitbucketAuthStatusMock.mockReset() + getBitbucketAuthStatusMock.mockResolvedValue({ + configured: false, + authenticated: false, + account: null + }) _resetPreflightCache() for (const key of Object.keys(handlers)) { @@ -58,19 +75,29 @@ describe('preflight', () => { }) }) + // Why: every preflight run probes (in order) `git --version`, `gh --version`, + // `glab --version`, then in parallel `gh auth status` + `glab auth status` — + // five execFile calls per cycle. Tests below provide values for all five. it('marks gh as authenticated when gh auth status exits successfully', async () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) .mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' }) + .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) const status = await runPreflightCheck() expect(status).toEqual({ git: { installed: true }, - gh: { installed: true, authenticated: true } + gh: { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true }, + bitbucket: { configured: false, authenticated: false, account: null } }) - expect(execFileAsyncMock).toHaveBeenNthCalledWith(3, 'gh', ['auth', 'status'], { + expect(execFileAsyncMock).toHaveBeenNthCalledWith(4, 'gh', ['auth', 'status'], { + encoding: 'utf-8' + }) + expect(execFileAsyncMock).toHaveBeenNthCalledWith(5, 'glab', ['auth', 'status'], { encoding: 'utf-8' }) }) @@ -79,7 +106,9 @@ describe('preflight', () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) .mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' }) + .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) const status = await runPreflightCheck() @@ -90,35 +119,71 @@ describe('preflight', () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) .mockRejectedValueOnce({ stderr: 'Logged in to github.com account octocat\n' }) + .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) const status = await runPreflightCheck() expect(status.gh).toEqual({ installed: true, authenticated: true }) }) + it('marks glab as not installed when `glab --version` fails', async () => { + execFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockRejectedValueOnce(new Error('command not found: glab')) + .mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' }) + + const status = await runPreflightCheck() + + expect(status.glab).toEqual({ installed: false, authenticated: false }) + // Why: with glab uninstalled, glab auth status must not run — that + // would surface a misleading "command not found" error in logs. + expect(execFileAsyncMock).toHaveBeenCalledTimes(4) + }) + + it('marks glab as installed but unauthenticated when auth status fails', async () => { + execFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) + .mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' }) + .mockRejectedValueOnce({ stderr: 'You are not logged into any GitLab hosts.\n' }) + + const status = await runPreflightCheck() + + expect(status.glab).toEqual({ installed: true, authenticated: false }) + }) + it('re-runs the probe when forced so updated gh auth state is visible without relaunch', async () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) .mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' }) + .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) .mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' }) + .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) const firstStatus = await runPreflightCheck() const refreshedStatus = await runPreflightCheck(true) expect(firstStatus.gh).toEqual({ installed: true, authenticated: false }) expect(refreshedStatus.gh).toEqual({ installed: true, authenticated: true }) - expect(execFileAsyncMock).toHaveBeenCalledTimes(6) + expect(execFileAsyncMock).toHaveBeenCalledTimes(10) }) it('registers the preflight handler', async () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) .mockResolvedValueOnce({ stdout: 'github.com\n' }) + .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) registerPreflightHandlers() @@ -126,7 +191,9 @@ describe('preflight', () => { expect(status).toEqual({ git: { installed: true }, - gh: { installed: true, authenticated: true } + gh: { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true }, + bitbucket: { configured: false, authenticated: false, account: null } }) }) @@ -134,10 +201,14 @@ describe('preflight', () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) .mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' }) + .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) .mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' }) + .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) registerPreflightHandlers() @@ -146,11 +217,15 @@ describe('preflight', () => { expect(firstStatus).toEqual({ git: { installed: true }, - gh: { installed: true, authenticated: false } + gh: { installed: true, authenticated: false }, + glab: { installed: true, authenticated: true }, + bitbucket: { configured: false, authenticated: false, account: null } }) expect(refreshedStatus).toEqual({ git: { installed: true }, - gh: { installed: true, authenticated: true } + gh: { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true }, + bitbucket: { configured: false, authenticated: false, account: null } }) }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 39b9e3121ca..31022751e41 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -5,6 +5,7 @@ import path from 'path' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { getBitbucketAuthStatus } from '../bitbucket/client' import { getActiveMultiplexer } from './ssh' const execFileAsync = promisify(execFile) @@ -12,6 +13,12 @@ const execFileAsync = promisify(execFile) export type PreflightStatus = { git: { installed: boolean } gh: { installed: boolean; authenticated: boolean } + // Why: optional so existing renderer call sites that only render git/gh + // status keep typechecking. Consumers that surface GitLab-specific + // affordances (the GitLab tab in the source picker, MR list, etc.) + // gate on `glab?.authenticated`. + glab?: { installed: boolean; authenticated: boolean } + bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } } // Why: cache the result so repeated Landing mounts don't re-spawn processes. @@ -118,21 +125,42 @@ async function isGhAuthenticated(): Promise { } } +// Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth +// status to stderr in some versions and stdout in others; check both. +async function isGlabAuthenticated(): Promise { + try { + await execFileAsync('glab', ['auth', 'status'], { encoding: 'utf-8' }) + return true + } catch (error) { + const stdout = (error as { stdout?: string }).stdout ?? '' + const stderr = (error as { stderr?: string }).stderr ?? '' + const output = `${stdout}\n${stderr}` + return output.includes('Logged in') + } +} + export async function runPreflightCheck(force = false): Promise { if (cached && !force) { return cached } - const [gitInstalled, ghInstalled] = await Promise.all([ + const [gitInstalled, ghInstalled, glabInstalled] = await Promise.all([ isCommandAvailable('git'), - isCommandAvailable('gh') + isCommandAvailable('gh'), + isCommandAvailable('glab') ]) - const ghAuthenticated = ghInstalled ? await isGhAuthenticated() : false + const [ghAuthenticated, glabAuthenticated, bitbucket] = await Promise.all([ + ghInstalled ? isGhAuthenticated() : Promise.resolve(false), + glabInstalled ? isGlabAuthenticated() : Promise.resolve(false), + getBitbucketAuthStatus() + ]) cached = { git: { installed: gitInstalled }, - gh: { installed: ghInstalled, authenticated: ghAuthenticated } + gh: { installed: ghInstalled, authenticated: ghAuthenticated }, + glab: { installed: glabInstalled, authenticated: glabAuthenticated }, + bitbucket } return cached diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 56ae9230756..94897ad71dd 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -37,6 +37,8 @@ const { registerFilesystemWatcherHandlersMock, registerAppHandlersMock, registerLinearHandlersMock, + registerGitLabHandlersMock, + registerHostedReviewHandlersMock, registerExportHandlersMock, registerOnboardingHandlersMock, registerSpeechHandlersMock @@ -75,6 +77,8 @@ const { registerFilesystemWatcherHandlersMock: vi.fn(), registerAppHandlersMock: vi.fn(), registerLinearHandlersMock: vi.fn(), + registerGitLabHandlersMock: vi.fn(), + registerHostedReviewHandlersMock: vi.fn(), registerExportHandlersMock: vi.fn(), registerOnboardingHandlersMock: vi.fn(), registerSpeechHandlersMock: vi.fn() @@ -219,6 +223,14 @@ vi.mock('./linear', () => ({ registerLinearHandlers: registerLinearHandlersMock })) +vi.mock('./gitlab', () => ({ + registerGitLabHandlers: registerGitLabHandlersMock +})) + +vi.mock('./hosted-review', () => ({ + registerHostedReviewHandlers: registerHostedReviewHandlersMock +})) + import { registerCoreHandlers } from './register-core-handlers' describe('registerCoreHandlers', () => { @@ -257,6 +269,8 @@ describe('registerCoreHandlers', () => { registerFilesystemWatcherHandlersMock.mockReset() registerAppHandlersMock.mockReset() registerLinearHandlersMock.mockReset() + registerGitLabHandlersMock.mockReset() + registerHostedReviewHandlersMock.mockReset() registerExportHandlersMock.mockReset() registerSpeechHandlersMock.mockReset() }) @@ -291,6 +305,8 @@ describe('registerCoreHandlers', () => { expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() + expect(registerGitLabHandlersMock).toHaveBeenCalledWith(store) + expect(registerHostedReviewHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerFeedbackHandlersMock).toHaveBeenCalled() expect(registerStatsHandlersMock).toHaveBeenCalledWith(stats) expect(registerMemoryHandlersMock).toHaveBeenCalledWith(store) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 33641cfd707..ca013544ad9 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -9,6 +9,8 @@ import { registerFilesystemWatcherHandlers } from './filesystem-watcher' import { registerClaudeUsageHandlers } from './claude-usage' import { registerCodexUsageHandlers } from './codex-usage' import { registerGitHubHandlers } from './github' +import { registerGitLabHandlers } from './gitlab' +import { registerHostedReviewHandlers } from './hosted-review' import { registerLinearHandlers } from './linear' import { registerFeedbackHandlers } from './feedback' import { registerExportHandlers } from './export' @@ -85,6 +87,8 @@ export function registerCoreHandlers( registerClaudeAccountHandlers(claudeAccounts) registerRateLimitHandlers(rateLimits) registerGitHubHandlers(store, stats) + registerGitLabHandlers(store) + registerHostedReviewHandlers(store, stats) registerLinearHandlers() registerFeedbackHandlers() registerExportHandlers() diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 0715e891675..36927838fca 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: these worktree path/name tests share a +single setup-free pure-logic module, and splitting them would make the related +edge cases harder to audit together. */ import { join, resolve } from 'path' import { describe, expect, it } from 'vitest' import { @@ -212,6 +215,8 @@ describe('mergeWorktree', () => { linkedIssue: 42, linkedPR: 10, linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, isArchived: true, isUnread: true, isPinned: true, @@ -232,6 +237,8 @@ describe('mergeWorktree', () => { linkedIssue: 42, linkedPR: 10, linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, isArchived: true, isUnread: true, isPinned: true, diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index f446b2d5eca..fe2c086fc61 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -187,6 +187,8 @@ export function mergeWorktree( linkedIssue: meta?.linkedIssue ?? null, linkedPR: meta?.linkedPR ?? null, linkedLinearIssue: meta?.linkedLinearIssue ?? null, + linkedGitLabMR: meta?.linkedGitLabMR ?? null, + linkedGitLabIssue: meta?.linkedGitLabIssue ?? null, isArchived: meta?.isArchived ?? false, isUnread: meta?.isUnread ?? false, isPinned: meta?.isPinned ?? false, diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 8c7a1c0bc75..eef52f3d289 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -17,6 +17,8 @@ import { removeWorktree } from '../git/worktree' import { gitExecFileAsync } from '../git/runner' import { getDefaultRemote } from '../git/repo' import { getPullRequestPushTarget, getWorkItem } from '../github/client' +import { getProjectRef as getGlabProjectRef, getGlabKnownHosts } from '../gitlab/gl-utils' +import { getWorkItemByProjectRef as getGitLabWorkItemByProjectRef } from '../gitlab/client' import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { @@ -398,6 +400,111 @@ export function registerWorktreeHandlers( } ) + // Why: GitLab parallel of worktrees:resolvePrBase. Same shape, same + // semantics — caller passes mrIid (with optional source_branch + + // isCrossRepository hints from the picker) and we return either a + // `/` ref (same-project MRs) or a SHA fetched + // from refs/merge-requests//head (fork MRs). The returned value + // is the workspace's base ref; the new worktree branch derives from + // the workspace name, not from the source ref. + ipcMain.handle( + 'worktrees:resolveMrBase', + async ( + _event, + args: { + repoId: string + mrIid: number + sourceBranch?: string + isCrossRepository?: boolean + } + ): Promise<{ baseBranch: string } | { error: string }> => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return { error: 'Repo not found' } + } + // Why: parity with the gh-side guard above. Remote SSH repos are + // out of v1 scope; the picker disables the GitLab tab for them too. + if (repo.connectionId) { + return { error: 'MR start points are not supported for remote repos yet.' } + } + if (isFolderRepo(repo)) { + return { error: 'Folder mode does not support creating worktrees.' } + } + + let sourceBranch = args.sourceBranch?.trim() ?? '' + let isCrossRepository = args.isCrossRepository === true + + if (!sourceBranch) { + const knownHosts = await getGlabKnownHosts() + const projectRef = await getGlabProjectRef(repo.path, knownHosts) + if (!projectRef) { + return { error: 'No GitLab project found for this repository.' } + } + const item = await getGitLabWorkItemByProjectRef(repo.path, projectRef, args.mrIid, 'mr') + if (!item || item.type !== 'mr') { + return { error: `MR !${args.mrIid} not found.` } + } + sourceBranch = (item.branchName ?? '').trim() + if (!sourceBranch) { + return { error: `MR !${args.mrIid} has no source branch.` } + } + if (item.isCrossRepository === true) { + isCrossRepository = true + } + } + + let remote: string + try { + remote = await getDefaultRemote(repo.path) + } catch (error) { + return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } + } + + // Why: GitLab exposes every MR head (fork or same-project) as + // refs/merge-requests//head on the target project. Using that + // ref lets us snapshot fork MRs without configuring the fork as a + // remote — same SHA-as-baseBranch shape as the gh-side branch above. + if (isCrossRepository) { + const mrRef = `refs/merge-requests/${args.mrIid}/head` + try { + await gitExecFileAsync(['fetch', remote, mrRef], { cwd: repo.path }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to fetch ${mrRef}: ${message.split('\n')[0]}` } + } + let sha: string + try { + const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', 'FETCH_HEAD'], { + cwd: repo.path + }) + sha = stdout.trim() + } catch { + return { error: `Could not resolve fork MR !${args.mrIid} head after fetch.` } + } + if (!sha) { + return { error: `Empty SHA resolving fork MR !${args.mrIid} head.` } + } + return { baseBranch: sha } + } + + try { + await gitExecFileAsync(['fetch', remote, sourceBranch], { cwd: repo.path }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { error: `Failed to fetch ${remote}/${sourceBranch}: ${message.split('\n')[0]}` } + } + + const remoteRef = `${remote}/${sourceBranch}` + try { + await gitExecFileAsync(['rev-parse', '--verify', remoteRef], { cwd: repo.path }) + } catch { + return { error: `Remote ref ${remoteRef} does not exist after fetch.` } + } + + return { baseBranch: remoteRef } + } + ) + ipcMain.handle( 'worktrees:remove', async (_event, args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index ee8eb7094c6..2d09b3c9086 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1700,6 +1700,8 @@ function getDefaultWorktreeMeta(): WorktreeMeta { linkedIssue: null, linkedPR: null, linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, isArchived: false, isUnread: false, isPinned: false, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d04e6d6efec..cfba17c75c0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -167,6 +167,8 @@ const store = { linkedIssue: 123, linkedPR: null, linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, isArchived: false, isUnread: false, isPinned: false, @@ -2344,6 +2346,8 @@ describe('OrcaRuntimeService', () => { linkedIssue: meta.linkedIssue ?? existingMeta?.linkedIssue ?? null, linkedPR: meta.linkedPR ?? existingMeta?.linkedPR ?? null, linkedLinearIssue: meta.linkedLinearIssue ?? existingMeta?.linkedLinearIssue ?? null, + linkedGitLabMR: meta.linkedGitLabMR ?? existingMeta?.linkedGitLabMR ?? null, + linkedGitLabIssue: meta.linkedGitLabIssue ?? existingMeta?.linkedGitLabIssue ?? null, isArchived: meta.isArchived ?? existingMeta?.isArchived ?? false, isUnread: meta.isUnread ?? existingMeta?.isUnread ?? false, isPinned: meta.isPinned ?? existingMeta?.isPinned ?? false, diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts new file mode 100644 index 00000000000..ffd29cd1d9b --- /dev/null +++ b/src/main/source-control/hosted-review.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + getProjectSlugMock, + getMergeRequestForBranchMock, + getRepoSlugMock, + getPRForBranchMock, + getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranchMock +} = vi.hoisted(() => ({ + getProjectSlugMock: vi.fn(), + getMergeRequestForBranchMock: vi.fn(), + getRepoSlugMock: vi.fn(), + getPRForBranchMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getBitbucketPullRequestForBranchMock: vi.fn() +})) + +vi.mock('../gitlab/client', () => ({ + getProjectSlug: getProjectSlugMock, + getMergeRequestForBranch: getMergeRequestForBranchMock, + getMergeRequest: vi.fn() +})) + +vi.mock('../github/client', () => ({ + getRepoSlug: getRepoSlugMock, + getPRForBranch: getPRForBranchMock +})) + +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: getBitbucketPullRequestForBranchMock, + getBitbucketPullRequest: vi.fn() +})) + +import { getHostedReviewForBranch } from './hosted-review' + +describe('getHostedReviewForBranch', () => { + beforeEach(() => { + getProjectSlugMock.mockReset() + getMergeRequestForBranchMock.mockReset() + getRepoSlugMock.mockReset() + getPRForBranchMock.mockReset() + getBitbucketRepoSlugMock.mockReset() + getBitbucketPullRequestForBranchMock.mockReset() + }) + + it('maps GitLab merge requests into the hosted review surface', async () => { + getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'g/p' }) + getMergeRequestForBranchMock.mockResolvedValue({ + number: 7, + title: 'GitLab branch', + state: 'opened', + url: 'https://gitlab.com/g/p/-/merge_requests/7', + pipelineStatus: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'MERGEABLE' + }) + + await expect( + getHostedReviewForBranch({ repoPath: '/repo', branch: 'refs/heads/feature' }) + ).resolves.toEqual({ + provider: 'gitlab', + number: 7, + title: 'GitLab branch', + state: 'open', + url: 'https://gitlab.com/g/p/-/merge_requests/7', + status: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'MERGEABLE' + }) + expect(getPRForBranchMock).not.toHaveBeenCalled() + }) + + it('falls through to GitHub when origin is not GitLab', async () => { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue({ owner: 'o', repo: 'r' }) + getPRForBranchMock.mockResolvedValue({ + number: 3, + title: 'GitHub branch', + state: 'open', + url: 'https://github.com/o/r/pull/3', + checksStatus: 'pending', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'UNKNOWN' + }) + + await expect( + getHostedReviewForBranch({ + repoPath: '/repo', + branch: 'feature', + linkedGitHubPR: 3 + }) + ).resolves.toMatchObject({ + provider: 'github', + number: 3, + status: 'pending' + }) + expect(getPRForBranchMock).toHaveBeenCalledWith('/repo', 'feature', 3) + }) + + it('falls through to Bitbucket when origin is not GitLab or GitHub', async () => { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue({ workspace: 'team', repoSlug: 'orca' }) + getBitbucketPullRequestForBranchMock.mockResolvedValue({ + number: 11, + title: 'Bitbucket branch', + state: 'open', + url: 'https://bitbucket.org/team/orca/pull-requests/11', + status: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'UNKNOWN', + headSha: 'abc123' + }) + + await expect( + getHostedReviewForBranch({ + repoPath: '/repo', + branch: 'feature/bitbucket', + linkedBitbucketPR: 11 + }) + ).resolves.toEqual({ + provider: 'bitbucket', + number: 11, + title: 'Bitbucket branch', + state: 'open', + url: 'https://bitbucket.org/team/orca/pull-requests/11', + status: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'UNKNOWN', + headSha: 'abc123' + }) + expect(getBitbucketPullRequestForBranchMock).toHaveBeenCalledWith( + '/repo', + 'feature/bitbucket', + 11 + ) + }) +}) diff --git a/src/main/source-control/hosted-review.ts b/src/main/source-control/hosted-review.ts new file mode 100644 index 00000000000..a8f848d211e --- /dev/null +++ b/src/main/source-control/hosted-review.ts @@ -0,0 +1,125 @@ +import type { HostedReviewInfo } from '../../shared/hosted-review' +import type { MRInfo, PRInfo } from '../../shared/types' +import { + getBitbucketPullRequest, + getBitbucketPullRequestForBranch, + getBitbucketRepoSlug +} from '../bitbucket/client' +import type { BitbucketPullRequestInfo } from '../bitbucket/pull-request-mappers' +import { getPRForBranch, getRepoSlug } from '../github/client' +import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' + +function mapGitHubReview(pr: PRInfo): HostedReviewInfo { + return { + provider: 'github', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.checksStatus, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}), + ...(pr.conflictSummary ? { conflictSummary: pr.conflictSummary } : {}) + } +} + +function mapGitLabReviewState(state: MRInfo['state']): HostedReviewInfo['state'] { + if (state === 'opened' || state === 'locked') { + return 'open' + } + return state +} + +function mapGitLabReview(mr: MRInfo): HostedReviewInfo { + return { + provider: 'gitlab', + number: mr.number, + title: mr.title, + state: mapGitLabReviewState(mr.state), + url: mr.url, + status: mr.pipelineStatus, + updatedAt: mr.updatedAt, + mergeable: mr.mergeable, + ...(mr.headSha ? { headSha: mr.headSha } : {}), + ...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {}) + } +} + +function mapBitbucketReview(pr: BitbucketPullRequestInfo): HostedReviewInfo { + return { + provider: 'bitbucket', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + status: pr.status, + updatedAt: pr.updatedAt, + mergeable: pr.mergeable, + ...(pr.headSha ? { headSha: pr.headSha } : {}) + } +} + +export async function getHostedReviewForBranch(input: { + repoPath: string + branch: string + linkedGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null +}): Promise { + const branchName = input.branch.replace(/^refs\/heads\//, '') + if ( + !branchName && + input.linkedGitHubPR == null && + input.linkedGitLabMR == null && + input.linkedBitbucketPR == null + ) { + return null + } + + // Why: branch review status is tied to the branch publishing remote. + // GitHub and GitLab task/project surfaces may use richer per-provider + // source preferences, but this core status should follow origin. + const gitlabProject = await getProjectSlug(input.repoPath) + if (gitlabProject) { + const mr = + (await getMergeRequestForBranch(input.repoPath, branchName, input.linkedGitLabMR ?? null)) ?? + null + return mr ? mapGitLabReview(mr) : null + } + + const githubRepo = await getRepoSlug(input.repoPath) + if (githubRepo) { + const pr = await getPRForBranch(input.repoPath, branchName, input.linkedGitHubPR ?? null) + return pr ? mapGitHubReview(pr) : null + } + + const bitbucketRepo = await getBitbucketRepoSlug(input.repoPath) + if (bitbucketRepo) { + const pr = await getBitbucketPullRequestForBranch( + input.repoPath, + branchName, + input.linkedBitbucketPR ?? null + ) + return pr ? mapBitbucketReview(pr) : null + } + + return null +} + +export async function getHostedReviewByNumber(input: { + repoPath: string + provider: 'github' | 'gitlab' | 'bitbucket' + number: number +}): Promise { + if (input.provider === 'gitlab') { + const mr = await getMergeRequest(input.repoPath, input.number) + return mr ? mapGitLabReview(mr) : null + } + if (input.provider === 'bitbucket') { + const pr = await getBitbucketPullRequest(input.repoPath, input.number) + return pr ? mapBitbucketReview(pr) : null + } + const pr = await getPRForBranch(input.repoPath, '', input.number) + return pr ? mapGitHubReview(pr) : null +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1dc307da57c..f7342f091f0 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines -- Why: the preload contract is intentionally centralized in one declaration file so renderer and preload stay in lockstep when IPC surfaces change. */ +import type { HostedReviewForBranchArgs, HostedReviewInfo } from '../shared/hosted-review' import type { BaseRefDefaultResult, BrowserCookieImportResult, @@ -30,6 +31,18 @@ import type { GitHubWorkItem, GitHubWorkItemDetails, GitHubViewer, + GitLabAssignableUser, + GitLabCommentResult, + GitLabIssueInfo, + GitLabIssueUpdate, + GitLabProjectRef, + GitLabTodo, + GitLabViewer, + GitLabWorkItem, + GitLabWorkItemDetails, + ListMergeRequestsResult, + MRInfo, + MRListState, ListWorkItemsResult, IssueInfo, LinearViewer, @@ -284,6 +297,10 @@ export type DetectedBrowserInfo = { export type PreflightStatus = { git: { installed: boolean } gh: { installed: boolean; authenticated: boolean } + /** Optional — older preload payloads predating GitLab support don't + * include it. Consumers gate on `glab?.installed` / `authenticated`. */ + glab?: { installed: boolean; authenticated: boolean } + bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } } export type RefreshAgentsResult = { @@ -488,6 +505,15 @@ export type PreloadApi = { headRefName?: string isCrossRepository?: boolean }) => Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> + /** GitLab parallel of resolvePrBase. For same-project MRs returns + * `/`; for fork MRs fetches + * refs/merge-requests//head and returns the SHA. */ + resolveMrBase: (args: { + repoId: string + mrIid: number + sourceBranch?: string + isCrossRepository?: boolean + }) => Promise<{ baseBranch: string } | { error: string }> remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise persistSortOrder: (args: { orderedIds: string[] }) => Promise @@ -725,6 +751,89 @@ export type PreloadApi = { listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs) => Promise updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs) => Promise } + hostedReview: { + forBranch: (args: HostedReviewForBranchArgs) => Promise + } + // ── GitLab — parallel to gh, MR/issue surface only in v1 ──────── + // Shapes mirror gh.* one-to-one where the data matches; diverge + // where GitLab's API differs (MR state values, project path with + // host, paginated envelope from `glab api -i`). + gl: { + viewer: () => Promise + projectSlug: (args: { repoPath: string }) => Promise + mrForBranch: (args: { + repoPath: string + branch: string + linkedMRIid?: number | null + }) => Promise + mr: (args: { repoPath: string; iid: number }) => Promise + listMRs: (args: { + repoPath: string + state?: MRListState + page?: number + perPage?: number + }) => Promise + /** Combined MR + issue list filtered by state. Issues are skipped + * when state is 'merged' (issues don't merge). */ + listWorkItems: (args: { + repoPath: string + state?: MRListState + page?: number + perPage?: number + }) => Promise + issue: (args: { repoPath: string; number: number }) => Promise + listIssues: (args: { repoPath: string; limit?: number }) => Promise + createIssue: (args: { + repoPath: string + title: string + body: string + }) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> + updateIssue: (args: { + repoPath: string + number: number + updates: GitLabIssueUpdate + }) => Promise<{ ok: true } | { ok: false; error: string }> + addIssueComment: (args: { + repoPath: string + number: number + body: string + }) => Promise + listLabels: (args: { repoPath: string }) => Promise + listAssignableUsers: (args: { repoPath: string }) => Promise + /** Cross-project user-scoped todos (gitlab.com/dashboard/todos). */ + todos: (args: { repoPath: string }) => Promise + /** Aggregated dialog payload — body + discussions + pipeline jobs. */ + workItemDetails: (args: { + repoPath: string + iid: number + type: 'issue' | 'mr' + }) => Promise + closeMR: (args: { + repoPath: string + iid: number + }) => Promise<{ ok: true } | { ok: false; error: string }> + reopenMR: (args: { + repoPath: string + iid: number + }) => Promise<{ ok: true } | { ok: false; error: string }> + mergeMR: (args: { + repoPath: string + iid: number + method?: 'merge' | 'squash' | 'rebase' + }) => Promise<{ ok: true } | { ok: false; error: string }> + addMRComment: (args: { + repoPath: string + iid: number + body: string + }) => Promise + workItemByPath: (args: { + repoPath: string + host: string + path: string + iid: number + type: 'issue' | 'mr' + }) => Promise | null> + } linear: { connect: (args: { apiKey: string diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts new file mode 100644 index 00000000000..9a27ee1f1e6 --- /dev/null +++ b/src/preload/gitlab.ts @@ -0,0 +1,103 @@ +/* GitLab preload bindings — split out of `src/preload/index.ts` so + adding or changing a `gl.*` channel doesn't surface as a merge + conflict on every upstream sync of the much larger central preload + file. Composed back into `api.gl` from `index.ts`. */ +import { ipcRenderer } from 'electron' + +export const glApi = { + viewer: (): Promise => ipcRenderer.invoke('gitlab:viewer'), + + projectSlug: (args: { repoPath: string }): Promise => + ipcRenderer.invoke('gitlab:projectSlug', args), + + mrForBranch: (args: { + repoPath: string + branch: string + linkedMRIid?: number | null + }): Promise => ipcRenderer.invoke('gitlab:mrForBranch', args), + + mr: (args: { repoPath: string; iid: number }): Promise => + ipcRenderer.invoke('gitlab:mr', args), + + listMRs: (args: { + repoPath: string + state?: 'opened' | 'merged' | 'closed' | 'all' + page?: number + perPage?: number + }): Promise => ipcRenderer.invoke('gitlab:listMRs', args), + + listWorkItems: (args: { + repoPath: string + state?: 'opened' | 'merged' | 'closed' | 'all' + page?: number + perPage?: number + }): Promise => ipcRenderer.invoke('gitlab:listWorkItems', args), + + issue: (args: { repoPath: string; number: number }): Promise => + ipcRenderer.invoke('gitlab:issue', args), + + listIssues: (args: { repoPath: string; limit?: number }): Promise => + ipcRenderer.invoke('gitlab:listIssues', args), + + createIssue: (args: { + repoPath: string + title: string + body: string + }): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> => + ipcRenderer.invoke('gitlab:createIssue', args), + + updateIssue: (args: { + repoPath: string + number: number + updates: unknown + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gitlab:updateIssue', args), + + addIssueComment: (args: { repoPath: string; number: number; body: string }): Promise => + ipcRenderer.invoke('gitlab:addIssueComment', args), + + listLabels: (args: { repoPath: string }): Promise => + ipcRenderer.invoke('gitlab:listLabels', args), + + listAssignableUsers: (args: { repoPath: string }): Promise => + ipcRenderer.invoke('gitlab:listAssignableUsers', args), + + todos: (args: { repoPath: string }): Promise => + ipcRenderer.invoke('gitlab:todos', args), + + workItemDetails: (args: { + repoPath: string + iid: number + type: 'issue' | 'mr' + }): Promise => ipcRenderer.invoke('gitlab:workItemDetails', args), + + closeMR: (args: { + repoPath: string + iid: number + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gitlab:closeMR', args), + + reopenMR: (args: { + repoPath: string + iid: number + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gitlab:reopenMR', args), + + mergeMR: (args: { + repoPath: string + iid: number + method?: 'merge' | 'squash' | 'rebase' + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gitlab:mergeMR', args), + + addMRComment: (args: { repoPath: string; iid: number; body: string }): Promise => + ipcRenderer.invoke('gitlab:addMRComment', args), + + workItemByPath: (args: { + repoPath: string + host: string + path: string + iid: number + type: 'issue' | 'mr' + }): Promise => ipcRenderer.invoke('gitlab:workItemByPath', args) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index b54aeb71827..a54d1df241f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ review and type drift checks easier than scattering these bindings across module import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' +import { glApi } from './gitlab' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { @@ -96,6 +97,7 @@ import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +import type { HostedReviewForBranchArgs } from '../shared/hosted-review' type NativeDropResolution = | { target: 'editor' } @@ -440,6 +442,14 @@ const api = { }): Promise<{ baseBranch: string; pushTarget?: unknown } | { error: string }> => ipcRenderer.invoke('worktrees:resolvePrBase', args), + resolveMrBase: (args: { + repoId: string + mrIid: number + sourceBranch?: string + isCrossRepository?: boolean + }): Promise<{ baseBranch: string } | { error: string }> => + ipcRenderer.invoke('worktrees:resolveMrBase', args), + remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }): Promise => ipcRenderer.invoke('worktrees:remove', args), @@ -866,6 +876,16 @@ const api = { ): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) }, + hostedReview: { + forBranch: (args: HostedReviewForBranchArgs): Promise => + ipcRenderer.invoke('hostedReview:forBranch', args) + }, + + // Why: GitLab bindings live in `./gitlab` so adding or changing a + // `gl.*` channel doesn't surface as a merge conflict on every + // upstream sync of this central preload file. + gl: glApi, + linear: { connect: (args: { apiKey: string @@ -1022,6 +1042,8 @@ const api = { }): Promise<{ git: { installed: boolean } gh: { installed: boolean; authenticated: boolean } + glab?: { installed: boolean; authenticated: boolean } + bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } linear: { connected: boolean } }> => ipcRenderer.invoke('preflight:check', args), detectAgents: (): Promise => ipcRenderer.invoke('preflight:detectAgents'), diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx new file mode 100644 index 00000000000..62730a881d0 --- /dev/null +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -0,0 +1,515 @@ +/* eslint-disable max-lines -- Why: dialog co-locates header, three + tabs (Description / Conversation / Pipeline), comment composer, + and four mutation actions. Splitting any of these into separate + components would make the close/reopen/merge state coupling + non-obvious. The GitHub-side equivalent (GitHubItemDialog) carries + the same disable for the same reason. */ +/* Why: GitLab counterpart to GitHubItemDialog. Side sheet with three + tabs (Description / Conversation / Pipeline) and footer actions — + close/reopen, merge, and a top-level comment composer. Files / + inline review-comment positioning / approvals are deferred to v1.5 + since they mirror substantial GitHub-side surface area. */ +import React, { useCallback, useEffect, useState } from 'react' +import { CircleDot, ExternalLink, GitMerge, LoaderCircle, RefreshCw, Send } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { VisuallyHidden } from 'radix-ui' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { cn } from '@/lib/utils' +import type { + GitLabPipelineJob, + GitLabWorkItem, + GitLabWorkItemDetails, + MRComment +} from '../../../shared/types' + +type Props = { + item: GitLabWorkItem | null + repoPath: string | null + onClose: () => void + onCreateWorkspace?: (item: GitLabWorkItem) => void +} + +// Why: GitLab MR / issue states map onto a coarser palette than GitHub. +const STATE_TONE: Record = { + opened: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300', + closed: 'bg-rose-500/15 text-rose-700 dark:text-rose-300', + merged: 'bg-violet-500/15 text-violet-700 dark:text-violet-300', + locked: 'bg-rose-500/15 text-rose-700 dark:text-rose-300', + draft: 'bg-amber-500/15 text-amber-700 dark:text-amber-300' +} + +// Why: pipeline job statuses map to one of four visual buckets — keep +// the mapping local so the renderer doesn't depend on the backend's +// shared mapper module (which is main-process only). +function jobStatusTone(status: string): string { + switch (status) { + case 'success': + return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300' + case 'failed': + return 'bg-rose-500/15 text-rose-700 dark:text-rose-300' + case 'running': + case 'pending': + case 'created': + case 'preparing': + case 'waiting_for_resource': + case 'scheduled': + return 'bg-sky-500/15 text-sky-700 dark:text-sky-300' + case 'manual': + return 'bg-amber-500/15 text-amber-700 dark:text-amber-300' + case 'canceled': + case 'skipped': + default: + return 'bg-muted text-muted-foreground' + } +} + +function StateBadge({ state }: { state: GitLabWorkItem['state'] }): React.JSX.Element { + return ( + + {state} + + ) +} + +function CommentCard({ comment }: { comment: MRComment }): React.JSX.Element { + return ( +
+
+
+ {comment.authorAvatarUrl ? ( + { + e.currentTarget.style.display = 'none' + }} + /> + ) : null} + {comment.author} + {comment.isResolved ? ( + + resolved + + ) : null} +
+ {comment.createdAt ? new Date(comment.createdAt).toLocaleDateString() : ''} +
+ {comment.path ? ( +
+ {comment.path} + {comment.line ? `:${comment.line}` : ''} +
+ ) : null} + +
+ ) +} + +function PipelineJobRow({ job }: { job: GitLabPipelineJob }): React.JSX.Element { + return ( + + ) +} + +export default function GitLabItemDialog({ + item, + repoPath, + onClose, + onCreateWorkspace +}: Props): React.JSX.Element { + const [details, setDetails] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [refreshNonce, setRefreshNonce] = useState(0) + const [commentDraft, setCommentDraft] = useState('') + const [commentSubmitting, setCommentSubmitting] = useState(false) + const [actionInFlight, setActionInFlight] = useState<'close' | 'reopen' | 'merge' | null>(null) + + useEffect(() => { + if (!item || !repoPath) { + setDetails(null) + setLoading(false) + setError(null) + return + } + let stale = false + setLoading(true) + setError(null) + void window.api.gl + .workItemDetails({ repoPath, iid: item.number, type: item.type }) + .then((data) => { + if (stale) { + return + } + if (!data) { + setError('Item not found.') + return + } + setDetails(data as GitLabWorkItemDetails) + }) + .catch((err) => { + if (!stale) { + setError(err instanceof Error ? err.message : String(err)) + } + }) + .finally(() => { + if (!stale) { + setLoading(false) + } + }) + return () => { + stale = true + } + }, [item, repoPath, refreshNonce]) + + // Why: clear the comment draft when the sheet target changes so the + // user doesn't accidentally post one MR's draft against another. + useEffect(() => { + setCommentDraft('') + }, [item?.id]) + + const handleRefresh = useCallback(() => { + setRefreshNonce((n) => n + 1) + }, []) + + const handleClose = useCallback(async (): Promise => { + if (!item || !repoPath || item.type !== 'mr') { + return + } + setActionInFlight('close') + try { + const res = await window.api.gl.closeMR({ repoPath, iid: item.number }) + if (res.ok) { + toast.success(`Closed MR !${item.number}`) + handleRefresh() + } else { + toast.error(res.error) + } + } finally { + setActionInFlight(null) + } + }, [item, repoPath, handleRefresh]) + + const handleReopen = useCallback(async (): Promise => { + if (!item || !repoPath || item.type !== 'mr') { + return + } + setActionInFlight('reopen') + try { + const res = await window.api.gl.reopenMR({ repoPath, iid: item.number }) + if (res.ok) { + toast.success(`Reopened MR !${item.number}`) + handleRefresh() + } else { + toast.error(res.error) + } + } finally { + setActionInFlight(null) + } + }, [item, repoPath, handleRefresh]) + + const handleMerge = useCallback(async (): Promise => { + if (!item || !repoPath || item.type !== 'mr') { + return + } + setActionInFlight('merge') + try { + const res = await window.api.gl.mergeMR({ repoPath, iid: item.number }) + if (res.ok) { + toast.success(`Merged MR !${item.number}`) + handleRefresh() + } else { + toast.error(res.error) + } + } finally { + setActionInFlight(null) + } + }, [item, repoPath, handleRefresh]) + + const handleSubmitComment = useCallback(async (): Promise => { + const body = commentDraft.trim() + if (!body || !item || !repoPath) { + return + } + setCommentSubmitting(true) + try { + // Why: the IPC for issue comments takes `number`, MR takes `iid`. + // Branch on the item type to hit the right channel. + const res = + item.type === 'mr' + ? await window.api.gl.addMRComment({ repoPath, iid: item.number, body }) + : await window.api.gl.addIssueComment({ repoPath, number: item.number, body }) + if (res.ok) { + setCommentDraft('') + handleRefresh() + } else { + toast.error(res.error) + } + } finally { + setCommentSubmitting(false) + } + }, [commentDraft, item, repoPath, handleRefresh]) + + // Why: GitMerge for MRs visually disambiguates from GitBranch (and + // matches gitlab.com's MR iconography); CircleDot stays on issues. + const Icon = item?.type === 'mr' ? GitMerge : CircleDot + const prefix = item?.type === 'mr' ? '!' : '#' + const isMR = item?.type === 'mr' + const canClose = isMR && item?.state === 'opened' + const canReopen = isMR && item?.state === 'closed' + const canMerge = isMR && item?.state === 'opened' + + return ( + !open && onClose()}> + + + {item ? item.title : 'Work item'} + GitLab work item detail + + + {item ? ( + <> +
+
+ +
+
+ + {prefix} + {item.number} + + + {item.author ? by {item.author} : null} +
+

+ {item.title} +

+
+ +
+
+ + + + Description + + Conversation + {details?.comments?.length ? ( + + {details.comments.length} + + ) : null} + + {isMR ? ( + + Pipeline + {details?.pipelineJobs?.length ? ( + + {details.pipelineJobs.length} + + ) : null} + + ) : null} + + +
+ {error ? ( +
+ {error} +
+ ) : null} + + + {loading && !details ? ( +
+ +
+ ) : details?.body ? ( + + ) : ( +

No description.

+ )} +
+ + + {loading && !details ? ( +
+ +
+ ) : details?.comments?.length ? ( + details.comments.map((c) => ) + ) : ( +

No comments yet.

+ )} +
+ + {isMR ? ( + + {loading && !details ? ( +
+ +
+ ) : details?.pipelineJobs?.length ? ( +
+ {details.pipelineJobs.map((j) => ( + + ))} +
+ ) : ( +

No pipeline runs for this MR.

+ )} +
+ ) : null} +
+
+ +