Add mobile Tasks parity (#2452)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-21 20:26:07 -07:00
committed by GitHub
co-authored by Orca
parent 3efc669bd4
commit b1973657ea
123 changed files with 26492 additions and 1461 deletions
+83 -71
View File
@@ -35,7 +35,7 @@ vi.mock('./gl-utils', async () => {
}
})
import { getMergeRequest, getMergeRequestForBranch, listMergeRequests } from './client'
import { getMergeRequest, getMergeRequestForBranch, listMergeRequests, updateMR } from './client'
describe('gitlab client — MR operations', () => {
beforeEach(() => {
@@ -48,6 +48,10 @@ describe('gitlab client — MR operations', () => {
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
resolveIssueSourceMock.mockResolvedValue({
source: { host: 'gitlab.com', path: 'g/p' },
fellBack: false
})
})
describe('getMergeRequest', () => {
@@ -193,15 +197,14 @@ describe('gitlab client — MR operations', () => {
describe('listMergeRequests', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: await getProjectRefMock(),
source: { host: 'gitlab.com', path: 'g/p' },
fellBack: false
}))
})
it('returns MRs via glab CLI', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
it('returns MRs via the GitLab API', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 100,
iid: 1,
@@ -215,7 +218,8 @@ describe('gitlab client — MR operations', () => {
source_project_id: 5,
target_project_id: 5
}
])
]),
headers: { 'x-total': '1', 'x-total-pages': '1' }
})
const result = await listMergeRequests('/repo', 'opened', 1, 20)
@@ -231,58 +235,35 @@ describe('gitlab client — MR operations', () => {
isCrossRepository: false,
repoId: 'g/p'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
[
'mr',
'list',
'--output',
'json',
'--per-page',
'20',
'--page',
'1',
'--order',
'updated_at',
'--sort',
'desc',
'--repo',
'https://gitlab.com/g/p'
'projects/g%2Fp/merge_requests?page=1&per_page=20&order_by=updated_at&sort=desc&with_merge_status_recheck=false&state=opened'
],
{ cwd: '/repo' }
)
})
it("passes --all when state='all'", async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
it("omits state when state='all'", async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', 'all', 1, 20)
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(callArgs).toContain('--all')
expect(callArgs).not.toContain('--opened')
expect(callArgs).not.toContain('--merged')
expect(callArgs).not.toContain('--closed')
const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[]
expect(callArgs[0]).not.toContain('state=')
})
it('passes through Open / Merged / Closed states as flags', async () => {
it('passes through Open / Merged / Closed states as API params', async () => {
for (const state of ['opened', 'merged', 'closed'] as const) {
glabExecFileAsyncMock.mockReset()
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
glabApiWithHeadersMock.mockReset()
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', state, 1, 20)
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
if (state === 'opened') {
expect(callArgs).not.toContain('--opened')
} else {
expect(callArgs).toContain(`--${state}`)
}
const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[]
expect(callArgs[0]).toContain(`state=${state}`)
}
})
it('flags fork MRs as cross-repository', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 200,
iid: 2,
@@ -293,47 +274,78 @@ describe('gitlab client — MR operations', () => {
source_project_id: 11,
target_project_id: 5
}
])
]),
headers: {}
})
const result = await listMergeRequests('/repo', 'opened', 1, 20)
expect(result.items[0].isCrossRepository).toBe(true)
})
it('falls back to CLI when project ref is unresolved', async () => {
getProjectRefMock.mockResolvedValueOnce(null)
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
id: 100,
iid: 1,
title: 'fallback mr',
state: 'opened',
web_url: 'https://gitlab.example.com/fallback/-/merge_requests/1',
updated_at: '2026-05-05',
source_branch: 'feat',
target_branch: 'main',
author: { username: 'alice' },
source_project_id: 5,
target_project_id: 5
}
])
it('returns a not_found envelope when project ref is unresolved', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: null,
fellBack: false
})
const result = await listMergeRequests('/repo', 'opened')
expect(result.items).toHaveLength(1)
expect(result.items[0].title).toBe('fallback mr')
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(callArgs).toContain('--order')
expect(callArgs).toContain('updated_at')
expect(callArgs).not.toContain('--repo')
expect(result.items).toEqual([])
expect(result.error?.type).toBe('not_found')
expect(glabApiWithHeadersMock).not.toHaveBeenCalled()
})
it('classifies CLI errors into the result envelope', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
it('classifies API errors into the result envelope', async () => {
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
const result = await listMergeRequests('/repo', 'opened')
expect(result.error?.type).toBe('permission_denied')
expect(result.items).toEqual([])
})
})
describe('updateMR', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: { host: 'git.internal', path: 'g/p' },
fellBack: false
}))
})
it('updates title, body, and labels through the selected SSH GitLab host', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' })
await expect(
updateMR(
'/repo',
12,
{
title: 'Renamed',
body: 'Updated body',
addLabels: ['bug'],
removeLabels: ['stale']
},
'upstream',
'conn-1'
)
).resolves.toEqual({ ok: true })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'--hostname',
'git.internal',
'-X',
'PUT',
'projects/g%2Fp/merge_requests/12',
'-f',
'title=Renamed',
'-f',
'description=Updated body',
'-f',
'add_labels=bug',
'-f',
'remove_labels=stale'
],
{}
)
})
})
})
+52 -29
View File
@@ -53,22 +53,21 @@ describe('gitlab client — combined listWorkItems', () => {
})
it('merges MRs + issues and sorts by updatedAt desc', async () => {
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'mr') {
return {
stdout: 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
}
])
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: {}
})
glabExecFileAsyncMock.mockImplementation(async () => {
return {
stdout: JSON.stringify([
{
@@ -89,13 +88,13 @@ describe('gitlab client — combined listWorkItems', () => {
})
it("skips the issues fetch when state === 'merged'", async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
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).toHaveBeenCalledTimes(1)
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('passes the closed state through to the issues fetch', async () => {
@@ -104,11 +103,20 @@ describe('gitlab client — combined listWorkItems', () => {
})
await listWorkItems('/repo', 'closed', 1, 20)
const issuesCalls = glabExecFileAsyncMock.mock.calls.filter(
(call) => (call[0] as string[])[0] === 'api'
)
expect(issuesCalls).toHaveLength(1)
expect((issuesCalls[0][0] as string[])[1]).toContain('state=closed')
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(issuesCallPath.at(-1)).toContain('state=closed')
})
it('passes search queries through to merge request and issue fetches', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'opened', 1, 20, undefined, 'ambiguous selector')
const mergeRequestCallPath = glabApiWithHeadersMock.mock.calls[0][0] as string[]
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(mergeRequestCallPath[0]).toContain('search=ambiguous%20selector')
expect(issuesCallPath.at(-1)).toContain('search=ambiguous%20selector')
})
it("omits the state param when 'all'", async () => {
@@ -117,11 +125,26 @@ describe('gitlab client — combined listWorkItems', () => {
})
await listWorkItems('/repo', 'all', 1, 20)
const issuesCalls = glabExecFileAsyncMock.mock.calls.filter(
(call) => (call[0] as string[])[0] === 'api'
)
expect(issuesCalls).toHaveLength(1)
expect((issuesCalls[0][0] as string[])[1]).not.toContain('state=')
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(issuesCallPath.at(-1)).not.toContain('state=')
})
it('routes issue list fetches through the selected SSH GitLab host', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: { host: 'git.internal', path: 'g/p' },
fellBack: false
})
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'opened', 1, 20, 'upstream', undefined, 'conn-1')
expect(glabExecFileAsyncMock.mock.calls[0][0]).toEqual([
'api',
'--hostname',
'git.internal',
'projects/g%2Fp/issues?per_page=20&order_by=updated_at&sort=desc&state=opened'
])
})
it('returns a not_found error envelope when project ref is unresolved', async () => {
@@ -134,7 +157,7 @@ describe('gitlab client — combined listWorkItems', () => {
})
it('surfaces the MR error envelope into the combined result', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
const result = await listWorkItems('/repo', 'opened', 1, 20)
@@ -142,7 +165,7 @@ describe('gitlab client — combined listWorkItems', () => {
})
it('still returns issues when MRs error out', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 500'))
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' }
+268 -125
View File
@@ -16,10 +16,14 @@ import type {
import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers'
import {
acquire,
classifyGlabError,
classifyListIssuesError,
getGlabKnownHosts,
getProjectRef,
getProjectRefForRemote,
glabHostnameArgs,
glabRepoExecOptions,
glabApiWithHeaders,
glabExecFileAsync,
release,
resolveIssueSource,
@@ -33,25 +37,6 @@ function encodedProject(projectPath: string): string {
return encodeURIComponent(projectPath)
}
function projectRefToGlabRepo(projectRef: ProjectRef): string {
// Why: `glab mr list` otherwise infers from cwd and can ignore an
// upstream/origin preference. A full URL also works for self-hosted hosts.
return `https://${projectRef.host}/${projectRef.path}`
}
function mrListStateFlags(state: MRListState): string[] {
switch (state) {
case 'opened':
return []
case 'merged':
return ['--merged']
case 'closed':
return ['--closed']
case 'all':
return ['--all']
}
}
/**
* Get the authenticated GitLab viewer. Mirrors getAuthenticatedViewer
* from the GitHub client — returns null when glab is unavailable, the
@@ -80,9 +65,12 @@ export async function getAuthenticatedViewer(): Promise<GitLabViewer | null> {
* 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<ProjectRef | null> {
export async function getProjectSlug(
repoPath: string,
connectionId?: string | null
): Promise<ProjectRef | null> {
const knownHosts = await getGlabKnownHosts()
return getProjectRef(repoPath, knownHosts)
return getProjectRef(repoPath, knownHosts, connectionId)
}
/**
@@ -90,15 +78,23 @@ export async function getProjectSlug(repoPath: string): Promise<ProjectRef | nul
* 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<MRInfo | null> {
export async function getMergeRequest(
repoPath: string,
iid: number,
connectionId?: string | null
): Promise<MRInfo | null> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getProjectRef(repoPath, knownHosts)
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId)
await acquire()
try {
const args = projectRef
? ['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`]
? [
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`
]
: ['mr', 'view', String(iid), '--output', 'json']
const { stdout } = await glabExecFileAsync(args, { cwd: repoPath })
const { stdout } = await glabExecFileAsync(args, glabRepoExecOptions(repoPath, connectionId))
const data = JSON.parse(stdout) as Parameters<typeof mapMRInfo>[0] & {
head_pipeline?: { status?: string } | null
pipeline?: { status?: string } | null
@@ -125,14 +121,15 @@ export async function getMergeRequest(repoPath: string, iid: number): Promise<MR
export async function getMergeRequestForBranch(
repoPath: string,
branch: string,
linkedMRIid?: number | null
linkedMRIid?: number | null,
connectionId?: string | null
): Promise<MRInfo | null> {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedMRIid == null) {
return null
}
const knownHosts = await getGlabKnownHosts()
const projectRef = await getProjectRef(repoPath, knownHosts)
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId)
if (!projectRef) {
return null
}
@@ -142,9 +139,10 @@ export async function getMergeRequestForBranch(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as (Parameters<typeof mapMRInfo>[0] & {
head_pipeline?: { status?: string } | null
@@ -162,8 +160,12 @@ export async function getMergeRequestForBranch(
// 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 }
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}`
],
glabRepoExecOptions(repoPath, connectionId)
)
const raw = JSON.parse(stdout) as Parameters<typeof mapMRInfo>[0] & {
head_pipeline?: { status?: string } | null
@@ -187,80 +189,60 @@ export async function listMergeRequests(
state: MRListState = 'opened',
page = 1,
perPage = 20,
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
query?: string,
connectionId?: string | null
): Promise<ListMergeRequestsResult> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
// Why: MRs sit on `origin` in the fork model (the user's fork is where
// they push branches and submit MRs). Mirror github's `getOwnerRepo`
// call site by going through the upstream/origin preference resolver
// so cross-fork workflows reuse the same plumbing.
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
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 searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : ''
const path =
`projects/${encodedProject(projectRef.path)}/merge_requests?` +
`page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}`
const repoId = projectRef.path
await acquire()
try {
if (projectRef) {
// Why: use `glab mr list` (CLI) instead of the REST API directly.
// The CLI respects the user's glab auth configuration; `--repo`
// keeps upstream/origin preference resolution explicit.
const stateFlag = mrListStateFlags(state)
const { stdout } = await glabExecFileAsync(
[
'mr',
'list',
'--output',
'json',
'--per-page',
String(perPage),
'--page',
String(page),
'--order',
'updated_at',
'--sort',
'desc',
'--repo',
projectRefToGlabRepo(projectRef),
...stateFlag
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as Parameters<typeof mapMRToWorkItem>[0][]
return {
items: data.map((d) => mapMRToWorkItem(d, projectRef.path)),
page,
perPage,
// Why: the CLI doesn't return x-total headers, so totals are
// approximate. For the Tasks UI this is acceptable — pagination
// still works via page+per_page.
totalCount: data.length,
totalPages: data.length < perPage ? page : page + 1
}
}
// Fallback — let glab infer project from cwd. This path is taken when
// the repo's remote host is not in getGlabKnownHosts() (e.g. a fresh
// self-hosted instance), but glab itself can still resolve it from the
// local git config.
const stateFlag = mrListStateFlags(state)
const { stdout } = await glabExecFileAsync(
[
'mr',
'list',
'--output',
'json',
'--per-page',
String(perPage),
'--page',
String(page),
'--order',
'updated_at',
'--sort',
'desc',
...stateFlag
],
{ cwd: repoPath }
const { body, headers } = await glabApiWithHeaders(
[...glabHostnameArgs(projectRef, connectionId), path],
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as Parameters<typeof mapMRToWorkItem>[0][]
const data = JSON.parse(body) as Parameters<typeof mapMRToWorkItem>[0][]
return {
items: data.map((d) => mapMRToWorkItem(d, 'unknown')),
items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)),
page,
perPage,
totalCount: data.length,
totalPages: data.length < perPage ? page : page + 1
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)
@@ -277,6 +259,14 @@ export async function listMergeRequests(
}
}
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
@@ -287,20 +277,25 @@ export async function getWorkItemByProjectRef(
repoPath: string,
projectRef: ProjectRef,
iid: number,
type: 'issue' | 'mr'
type: 'issue' | 'mr',
connectionId?: string | null
): Promise<GitLabWorkItem | null> {
await acquire()
try {
const resource = type === 'mr' ? 'merge_requests' : 'issues'
const { stdout } = await glabExecFileAsync(
['api', `projects/${encodedProject(projectRef.path)}/${resource}/${iid}`],
{ cwd: repoPath }
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/${resource}/${iid}`
],
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout)
if (type === 'mr') {
return mapMRToWorkItem(data, projectRef.path)
return mapMRToWorkItem(data, projectRef.path, projectRef)
}
return mapIssueToWorkItem(data, projectRef.path)
return mapIssueToWorkItem(data, projectRef.path, projectRef)
} catch {
return null
} finally {
@@ -337,11 +332,18 @@ export async function listWorkItems(
state: MRListState = 'opened',
page = 1,
perPage = 20,
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
query?: string,
connectionId?: string | null
): Promise<GitLabPagedResult<GitLabWorkItem>> {
const issueState = mrStateToIssueState(state)
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
if (!projectRef) {
return {
items: [],
@@ -366,13 +368,13 @@ export async function listWorkItems(
// raw issues API directly and run mapIssueToWorkItem against the
// raw payload instead.
const [mrs, issues] = await Promise.all([
listMergeRequests(repoPath, state, page, perPage, preference),
listMergeRequests(repoPath, state, page, perPage, preference, query, connectionId),
issueState === null
? Promise.resolve({
items: [] as GitLabWorkItem[],
error: undefined as ClassifiedError | undefined
})
: fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage)
: fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage, query, connectionId)
])
const merged = [...mrs.items, ...issues.items].sort((a, b) =>
(b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')
@@ -401,21 +403,25 @@ export async function fetchIssuesAsWorkItems(
repoPath: string,
projectRef: ProjectRef,
state: IssueListState,
perPage: number
perPage: number,
query?: string,
connectionId?: string | null
): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> {
await acquire()
try {
const stateParam = state === 'all' ? '' : `&state=${state}`
const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : ''
const { stdout } = await glabExecFileAsync(
[
'api',
`projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}`
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as Parameters<typeof mapIssueToWorkItem>[0][]
return {
items: data.map((d) => mapIssueToWorkItem(d, projectRef.path)),
items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)),
error: undefined
}
} catch (err) {
@@ -439,15 +445,27 @@ export async function fetchIssuesAsWorkItems(
* work directly from a mention/assignment without going to gitlab.com
* first.
*/
export async function listTodos(repoPath: string): Promise<GitLabTodo[]> {
export async function listTodos(
repoPath: string,
connectionId?: string | null
): Promise<GitLabTodo[]> {
const projectRef = await getProjectRef(repoPath, await getGlabKnownHosts(), connectionId)
if (connectionId && !projectRef) {
return []
}
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 }
[
'api',
...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []),
'--paginate',
'todos?state=pending&per_page=50'
],
glabRepoExecOptions(repoPath, connectionId)
)
type RESTTodo = {
id?: number
@@ -498,11 +516,15 @@ export async function listTodos(repoPath: string): Promise<GitLabTodo[]> {
async function withProjectRef<T>(
repoPath: string,
preference: IssueSourcePreference | undefined,
connectionId: string | null | undefined,
explicitProjectRef: ProjectRef | null | undefined,
fn: (projectRef: ProjectRef, repoFlag: string) => Promise<T>,
fallback: T
): Promise<T> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getProjectRef(repoPath, knownHosts)
const projectRef =
explicitProjectRef ??
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
if (!projectRef) {
return fallback
}
@@ -511,14 +533,30 @@ async function withProjectRef<T>(
export async function closeMR(
repoPath: string,
iid: number
iid: number,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRef?: ProjectRef | null
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
async (_pr, repoFlag) => {
preference,
connectionId,
projectRef,
async (projectRef, repoFlag) => {
await acquire()
try {
await glabExecFileAsync(['mr', 'close', String(iid), '-R', repoFlag], { cwd: repoPath })
await glabExecFileAsync(
[
'mr',
'close',
String(iid),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
],
glabRepoExecOptions(repoPath, connectionId)
)
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
@@ -539,14 +577,30 @@ export async function closeMR(
export async function reopenMR(
repoPath: string,
iid: number
iid: number,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRef?: ProjectRef | null
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
async (_pr, repoFlag) => {
preference,
connectionId,
projectRef,
async (projectRef, repoFlag) => {
await acquire()
try {
await glabExecFileAsync(['mr', 'reopen', String(iid), '-R', repoFlag], { cwd: repoPath })
await glabExecFileAsync(
[
'mr',
'reopen',
String(iid),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
],
glabRepoExecOptions(repoPath, connectionId)
)
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
@@ -565,11 +619,17 @@ export async function reopenMR(
export async function mergeMR(
repoPath: string,
iid: number,
method: 'merge' | 'squash' | 'rebase' = 'merge'
method: 'merge' | 'squash' | 'rebase' = 'merge',
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRef?: ProjectRef | null
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
async (_pr, repoFlag) => {
preference,
connectionId,
projectRef,
async (projectRef, repoFlag) => {
await acquire()
try {
// Why: glab mr merge accepts --squash and --rebase flags;
@@ -578,8 +638,17 @@ export async function mergeMR(
const methodFlag =
method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : []
await glabExecFileAsync(
['mr', 'merge', String(iid), '-R', repoFlag, '--yes', ...methodFlag],
{ cwd: repoPath }
[
'mr',
'merge',
String(iid),
'-R',
repoFlag,
'--yes',
...methodFlag,
...glabHostnameArgs(projectRef, connectionId)
],
glabRepoExecOptions(repoPath, connectionId)
)
return { ok: true }
} catch (err) {
@@ -595,23 +664,30 @@ export async function mergeMR(
export async function addMRComment(
repoPath: string,
iid: number,
body: string
body: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRef?: ProjectRef | null
): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> {
return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>(
repoPath,
preference,
connectionId,
projectRef,
async (projectRef) => {
await acquire()
try {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'POST',
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`,
'-f',
`body=${body}`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as {
id?: number
@@ -641,6 +717,73 @@ export async function addMRComment(
)
}
export async function updateMR(
repoPath: string,
iid: number,
updates: {
title?: string
body?: string
addLabels?: string[]
removeLabels?: string[]
},
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRef?: ProjectRef | null
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
preference,
connectionId,
projectRef,
async (projectRef) => {
const fields: string[] = []
const title = updates.title?.trim()
if (updates.title !== undefined) {
if (!title) {
return { ok: false, error: 'Title is required' }
}
fields.push(`title=${title}`)
}
if (updates.body !== undefined) {
fields.push(`description=${updates.body}`)
}
const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0)
const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0)
if (addLabels.length > 0) {
fields.push(`add_labels=${addLabels.join(',')}`)
}
if (removeLabels.length > 0) {
fields.push(`remove_labels=${removeLabels.join(',')}`)
}
if (fields.length === 0) {
return { ok: true }
}
await acquire()
try {
await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'PUT',
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`,
...fields.flatMap((field) => ['-f', field])
],
glabRepoExecOptions(repoPath, connectionId)
)
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { ok: false, error: classifyGlabError(msg).message }
} finally {
release()
}
},
{ ok: false, error: 'Could not resolve GitLab project for this repository' }
)
}
/** Re-export so callers don't need to know the gl-utils module split. */
export { _resetProjectRefCache } from './gl-utils'
export {
+54 -3
View File
@@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/* eslint-disable max-lines -- Why: GitLab remote parsing coverage needs many URL/host fixtures against the same mocked git/glab helpers. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, glabExecFileAsyncMock } = vi.hoisted(() => ({
const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
glabExecFileAsyncMock: vi.fn()
glabExecFileAsyncMock: vi.fn(),
sshExecMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
@@ -18,11 +20,13 @@ import {
getIssueProjectRef,
getGlabKnownHosts,
getProjectRef,
getProjectRefForRemote,
parseGitLabProjectRef,
parseGlabApiResponse,
parseGlabAuthStatusHosts,
resolveIssueSource
} from './gl-utils'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
describe('gitlab project ref parsing', () => {
it('parses HTTPS and SSH GitLab.com remotes', () => {
@@ -99,9 +103,15 @@ describe('gitlab project ref parsing', () => {
describe('gitlab project ref resolution', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
sshExecMock.mockReset()
unregisterSshGitProvider('conn-1')
_resetProjectRefCache()
})
afterEach(() => {
unregisterSshGitProvider('conn-1')
})
it('keeps getProjectRef origin-based', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:fork/orca.git\n'
@@ -155,6 +165,47 @@ describe('gitlab project ref resolution', () => {
path: 'stablyai/orca'
})
})
it('resolves project refs through the SSH git provider for connected repos', async () => {
sshExecMock.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' })
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
host: 'gitlab.com',
path: 'remote/orca'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('does not cache a missing SSH provider as a permanent null project ref', async () => {
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull()
sshExecMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:remote/orca.git\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
host: 'gitlab.com',
path: 'remote/orca'
})
})
it('does not cache transient SSH exec failures as permanent null project refs', async () => {
sshExecMock
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' })
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull()
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
host: 'gitlab.com',
path: 'remote/orca'
})
})
})
describe('resolveIssueSource', () => {
+47 -15
View File
@@ -2,6 +2,7 @@ import { execFile } from 'child_process'
import { promisify } from 'util'
import { gitExecFileAsync, glabExecFileAsync } from '../git/runner'
import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
// Why: legacy generic execFile wrapper — only used by callers that don't need
// WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from
@@ -167,22 +168,34 @@ export function parseGitLabProjectRef(
export async function getProjectRefForRemote(
repoPath: string,
remoteName: string,
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS,
connectionId?: string | null
): Promise<ProjectRef | null> {
const cacheKey = `${repoPath}\0${remoteName}\0${knownHosts.join(',')}`
const cacheKey = `${connectionId ?? 'local'}\0${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 sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null
if (connectionId && !sshGitProvider) {
// Why: mobile can attempt GitLab loads before the SSH tunnel is ready.
// Caching that transient state would poison later loads after connect.
return null
}
const { stdout } = sshGitProvider
? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
: await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: repoPath })
const result = parseGitLabProjectRef(stdout, knownHosts)
if (result) {
projectRefCache.set(cacheKey, result)
return result
}
} catch {
if (connectionId) {
// Why: remote SSH failures are often transient tunnel/process errors.
// Do not cache them as "not a GitLab repo" for the rest of the session.
return null
}
// ignore — non-GitLab remote or no remote configured
}
projectRefCache.set(cacheKey, null)
@@ -191,20 +204,22 @@ export async function getProjectRefForRemote(
export async function getProjectRef(
repoPath: string,
knownHosts?: readonly string[]
knownHosts?: readonly string[],
connectionId?: string | null
): Promise<ProjectRef | null> {
return getProjectRefForRemote(repoPath, 'origin', knownHosts)
return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId)
}
export async function getIssueProjectRef(
repoPath: string,
knownHosts?: readonly string[]
knownHosts?: readonly string[],
connectionId?: string | null
): Promise<ProjectRef | null> {
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts)
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId)
if (upstream) {
return upstream
}
return getProjectRefForRemote(repoPath, 'origin', knownHosts)
return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId)
}
export type ResolvedIssueSource = {
@@ -222,23 +237,40 @@ export type ResolvedIssueSource = {
export async function resolveIssueSource(
repoPath: string,
preference: IssueSourcePreference | undefined,
knownHosts?: readonly string[]
knownHosts?: readonly string[],
connectionId?: string | null
): Promise<ResolvedIssueSource> {
if (preference === 'upstream') {
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts)
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId)
if (upstream) {
return { source: upstream, fellBack: false }
}
const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts)
const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId)
return { source: origin, fellBack: origin !== null }
}
if (preference === 'origin') {
return {
source: await getProjectRefForRemote(repoPath, 'origin', knownHosts),
source: await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId),
fellBack: false
}
}
return { source: await getIssueProjectRef(repoPath, knownHosts), fellBack: false }
return { source: await getIssueProjectRef(repoPath, knownHosts, connectionId), fellBack: false }
}
export function glabRepoExecOptions(
repoPath: string,
connectionId?: string | null
): { cwd?: string } {
return connectionId ? {} : { cwd: repoPath }
}
export function glabHostnameArgs(
projectRef: Pick<ProjectRef, 'host'> | null | undefined,
connectionId?: string | null
): string[] {
// Why: local glab commands can infer host from cwd; SSH-backed calls have
// no local cwd, so self-hosted instances need an explicit hostname.
return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : []
}
// ── Known-hosts discovery via `glab auth status` ────────────────────
+92 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: GitLab issue mutation/list coverage shares glab mocks across related endpoint cases. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GlUtils from './gl-utils'
@@ -30,7 +31,15 @@ vi.mock('./gl-utils', async () => {
}
})
import { addIssueComment, createIssue, getIssue, listIssues, updateIssue } from './issues'
import {
addIssueComment,
createIssue,
getIssue,
listAssignableUsers,
listIssues,
listLabels,
updateIssue
} from './issues'
describe('gitlab issue operations', () => {
beforeEach(() => {
@@ -226,6 +235,62 @@ describe('gitlab issue operations', () => {
)
})
it('updateIssue applies body edits via the issue API', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await expect(updateIssue('/repo-root', 5, { body: 'Updated body' })).resolves.toEqual({
ok: true
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', '-X', 'PUT', 'projects/stablyai%2Forca/issues/5', '-f', 'description=Updated body'],
{ cwd: '/repo-root' }
)
})
it('routes issue metadata reads through the selected SSH GitLab host', async () => {
getIssueProjectRefMock
.mockResolvedValueOnce({ host: 'git.internal', path: 'stablyai/orca' })
.mockResolvedValueOnce({ host: 'git.internal', path: 'stablyai/orca' })
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'bug\nfeature\n' })
.mockResolvedValueOnce({
stdout: '{"username":"alice","name":"Alice","avatar_url":"https://example.com/a.png"}\n'
})
await expect(listLabels('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([
'bug',
'feature'
])
await expect(listAssignableUsers('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([
{
username: 'alice',
name: 'Alice',
avatarUrl: 'https://example.com/a.png'
}
])
expect(glabExecFileAsyncMock.mock.calls[0][0]).toEqual([
'api',
'--hostname',
'git.internal',
'--paginate',
'projects/stablyai%2Forca/labels',
'--jq',
'.[].name'
])
expect(glabExecFileAsyncMock.mock.calls[1][0]).toEqual([
'api',
'--hostname',
'git.internal',
'--paginate',
'projects/stablyai%2Forca/members/all?per_page=100',
'--jq',
'.[] | {username, name, avatar_url}'
])
})
it('addIssueComment posts to /notes and maps the response', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({
@@ -256,6 +321,32 @@ describe('gitlab issue operations', () => {
)
})
it('addIssueComment passes hostname for SSH-backed self-hosted repos', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({
host: 'gitlab.example.com',
path: 'stablyai/orca'
})
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 100, body: 'Hello' })
})
await addIssueComment('/repo-root', 5, 'Hello', undefined, 'conn-1')
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'--hostname',
'gitlab.example.com',
'-X',
'POST',
'projects/stablyai%2Forca/issues/5/notes',
'-f',
'body=Hello'
],
{}
)
})
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
+114 -36
View File
@@ -13,7 +13,7 @@ import type {
} from '../../shared/types'
import { mapGitLabIssueInfo } from './mappers'
// prettier-ignore
import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts } from './gl-utils'
import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, type ProjectRef } from './gl-utils'
// Why: parallel to GitHub's IssueListResult — distinguishes a successful-
// empty listing from a failed fetch.
@@ -40,16 +40,21 @@ function encodedProject(projectPath: string): string {
*/
export async function getIssue(
repoPath: string,
issueNumber: number
issueNumber: number,
connectionId?: string | null
): Promise<GitLabIssueInfo | null> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId)
await acquire()
try {
if (projectRef) {
const { stdout } = await glabExecFileAsync(
['api', `projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`],
{ cwd: repoPath }
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
],
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout)
return mapGitLabIssueInfo(data)
@@ -57,7 +62,7 @@ export async function getIssue(
// Fallback for non-GitLab remotes — let glab infer the project from cwd.
const { stdout } = await glabExecFileAsync(
['issue', 'view', String(issueNumber), '--output', 'json'],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout)
return mapGitLabIssueInfo(data)
@@ -83,10 +88,16 @@ export async function listIssues(
limit = 20,
preference?: IssueSourcePreference,
state: IssueListState = 'opened',
assignee?: string
assignee?: string,
connectionId?: string | null
): Promise<IssueListResult> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
await acquire()
try {
if (projectRef) {
@@ -95,9 +106,10 @@ export async function listIssues(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as Record<string, unknown>[]
// Why: GitLab's project issues endpoint returns true issues only
@@ -126,7 +138,7 @@ export async function listIssues(
...stateFlag,
...assigneeFlag
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as unknown[]
return {
@@ -151,14 +163,20 @@ export async function createIssue(
repoPath: string,
title: string,
body: string,
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
connectionId?: string | null
): 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)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
if (!projectRef) {
return {
ok: false,
@@ -170,6 +188,7 @@ export async function createIssue(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'POST',
`projects/${encodedProject(projectRef.path)}/issues`,
@@ -179,7 +198,7 @@ export async function createIssue(
// Why: GitLab uses `description` (not `body`) for issue text.
`description=${body}`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as { iid?: number; web_url?: string; url?: string }
if (typeof data.iid !== 'number') {
@@ -201,19 +220,21 @@ export async function createIssue(
/**
* 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.
* Why: callers that list through a per-repo issue source preference must
* mutate the same GitLab project, or identical IIDs on origin/upstream can
* silently edit the wrong issue.
*/
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitLabIssueUpdate
updates: GitLabIssueUpdate,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRefOverride?: ProjectRef | null
): Promise<{ ok: true } | { ok: false; error: string }> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
const projectRef =
projectRefOverride ??
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
if (!projectRef) {
return {
ok: false,
@@ -229,9 +250,17 @@ export async function updateIssue(
await acquire()
try {
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
await glabExecFileAsync(['issue', cmd, String(issueNumber), '-R', repoFlag], {
cwd: repoPath
})
await glabExecFileAsync(
[
'issue',
cmd,
String(issueNumber),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
],
glabRepoExecOptions(repoPath, connectionId)
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
// Treat "already closed/reopened" as a no-op (matches gh path).
@@ -243,8 +272,38 @@ export async function updateIssue(
}
}
if (updates.body !== undefined) {
await acquire()
try {
await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'PUT',
`projects/${encodedProject(repoFlag)}/issues/${issueNumber}`,
'-f',
`description=${updates.body}`
],
glabRepoExecOptions(repoPath, connectionId)
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGlabError(stderr).message)
} finally {
release()
}
}
// Field edits via `glab issue update`.
const editArgs: string[] = ['issue', 'update', String(issueNumber), '-R', repoFlag]
const editArgs: string[] = [
'issue',
'update',
String(issueNumber),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
]
let hasEditArgs = false
if (updates.title) {
@@ -271,7 +330,7 @@ export async function updateIssue(
if (hasEditArgs) {
await acquire()
try {
await glabExecFileAsync(editArgs, { cwd: repoPath })
await glabExecFileAsync(editArgs, glabRepoExecOptions(repoPath, connectionId))
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGlabError(stderr).message)
@@ -293,10 +352,14 @@ export async function updateIssue(
export async function addIssueComment(
repoPath: string,
issueNumber: number,
body: string
body: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRefOverride?: ProjectRef | null
): Promise<GitLabCommentResult> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
const projectRef =
projectRefOverride ??
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
if (!projectRef) {
return {
ok: false,
@@ -308,13 +371,14 @@ export async function addIssueComment(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'POST',
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}/notes`,
'-f',
`body=${body}`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as {
id?: number
@@ -345,10 +409,16 @@ export async function addIssueComment(
export async function listLabels(
repoPath: string,
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
connectionId?: string | null
): Promise<string[]> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
if (!projectRef) {
return []
}
@@ -357,12 +427,13 @@ export async function listLabels(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/labels`,
'--jq',
'.[].name'
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
return stdout
.trim()
@@ -377,10 +448,16 @@ export async function listLabels(
export async function listAssignableUsers(
repoPath: string,
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
connectionId?: string | null
): Promise<GitLabAssignableUser[]> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
if (!projectRef) {
return []
}
@@ -393,12 +470,13 @@ export async function listAssignableUsers(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/members/all?per_page=100`,
'--jq',
'.[] | {username, name, avatar_url}'
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
type RESTMember = { username?: string; name?: string | null; avatar_url?: string | null }
const users: GitLabAssignableUser[] = []
+11 -4
View File
@@ -247,7 +247,11 @@ type GitLabMRRawForWorkItem = {
target_project_id?: number
}
export function mapMRToWorkItem(data: GitLabMRRawForWorkItem, repoId: string): GitLabWorkItem {
export function mapMRToWorkItem(
data: GitLabMRRawForWorkItem,
repoId: string,
projectRef?: GitLabWorkItem['projectRef']
): GitLabWorkItem {
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
const number = data.iid ?? 0
return {
@@ -268,7 +272,8 @@ export function mapMRToWorkItem(data: GitLabMRRawForWorkItem, repoId: string): G
data.source_project_id !== undefined &&
data.target_project_id !== undefined &&
data.source_project_id !== data.target_project_id,
repoId
repoId,
...(projectRef ? { projectRef } : {})
}
}
@@ -286,7 +291,8 @@ type GitLabIssueRawForWorkItem = {
export function mapIssueToWorkItem(
data: GitLabIssueRawForWorkItem,
repoId: string
repoId: string,
projectRef?: GitLabWorkItem['projectRef']
): GitLabWorkItem {
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
const number = data.iid ?? 0
@@ -303,7 +309,8 @@ export function mapIssueToWorkItem(
labels,
updatedAt: data.updated_at ?? '',
author: data.author?.username ?? null,
repoId
repoId,
...(projectRef ? { projectRef } : {})
}
}
+49 -82
View File
@@ -12,12 +12,14 @@ import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers'
import {
acquire,
getGlabKnownHosts,
getIssueProjectRef,
getProjectRef,
glabHostnameArgs,
glabRepoExecOptions,
glabExecFileAsync,
release,
resolveIssueSource,
type ProjectRef
} from './gl-utils'
import type { IssueSourcePreference } from '../../shared/types'
function encodedProject(projectPath: string): string {
return encodeURIComponent(projectPath)
@@ -79,16 +81,18 @@ async function fetchDiscussions(
repoPath: string,
projectRef: ProjectRef,
type: 'issue' | 'mr',
iid: number
iid: number,
connectionId?: string | null
): Promise<GitLabRawDiscussion[]> {
const resource = type === 'mr' ? 'merge_requests' : 'issues'
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
return JSON.parse(stdout) as GitLabRawDiscussion[]
}
@@ -118,15 +122,17 @@ function mapPipelineJob(raw: GitLabRawJob): GitLabPipelineJob {
async function fetchPipelineJobs(
repoPath: string,
projectRef: ProjectRef,
pipelineId: number
pipelineId: number,
connectionId?: string | null
): Promise<GitLabPipelineJob[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=100`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as GitLabRawJob[]
return data.map(mapPipelineJob)
@@ -157,33 +163,26 @@ type GitLabRawMR = Parameters<typeof mapMRToWorkItem>[0] & {
export async function getWorkItemDetails(
repoPath: string,
iid: number,
type: 'issue' | 'mr'
type: 'issue' | 'mr',
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRefOverride?: ProjectRef | null
): Promise<GitLabWorkItemDetails | null> {
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.
// Why: detail fetches must use the same project source as the list row
// that opened them, otherwise forked repos can show a row from one remote
// and a detail sheet from another.
const projectRef =
type === 'issue'
? await getIssueProjectRef(repoPath, knownHosts)
: await getProjectRef(repoPath, knownHosts)
projectRefOverride ??
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
if (!projectRef) {
return null
}
await acquire()
try {
if (projectRef) {
if (type === 'issue') {
return await fetchIssueDetails(repoPath, projectRef, iid)
}
return await fetchMRDetails(repoPath, projectRef, iid)
}
// Fallback — let glab infer project from cwd. This path is taken when
// the repo's remote host is not in getGlabKnownHosts() (e.g. a fresh
// self-hosted instance), but glab itself can still resolve it from the
// local git config.
if (type === 'issue') {
return await fetchIssueDetailsFallback(repoPath, iid)
return await fetchIssueDetails(repoPath, projectRef, iid, connectionId)
}
return await fetchMRDetailsFallback(repoPath, iid)
return await fetchMRDetails(repoPath, projectRef, iid, connectionId)
} catch {
return null
} finally {
@@ -194,19 +193,25 @@ export async function getWorkItemDetails(
async function fetchIssueDetails(
repoPath: string,
projectRef: ProjectRef,
iid: number
iid: number,
connectionId?: string | null
): Promise<GitLabWorkItemDetails | null> {
// 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)
glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues/${iid}`
],
glabRepoExecOptions(repoPath, connectionId)
),
fetchDiscussions(repoPath, projectRef, 'issue', iid, connectionId)
])
const issueRaw = JSON.parse(issueRes.stdout) as GitLabRawIssue
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
const full = mapIssueToWorkItem(issueRaw, projectRef.path)
const full = mapIssueToWorkItem(issueRaw, projectRef.path, projectRef)
// 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.
@@ -223,54 +228,36 @@ async function fetchIssueDetails(
}
}
async function fetchIssueDetailsFallback(
repoPath: string,
iid: number
): Promise<GitLabWorkItemDetails | null> {
const { stdout } = await glabExecFileAsync(['issue', 'view', String(iid), '--output', 'json'], {
cwd: repoPath
})
const issueRaw = JSON.parse(stdout) as GitLabRawIssue
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
const full = mapIssueToWorkItem(issueRaw, 'unknown')
const { repoId: _repoId, ...rest } = full
return rest
})()
return {
item,
body: issueRaw.description ?? '',
comments: [],
assignees: (issueRaw.assignees ?? [])
.map((a) => a?.username)
.filter((u): u is string => typeof u === 'string')
}
}
async function fetchMRDetails(
repoPath: string,
projectRef: ProjectRef,
iid: number
iid: number,
connectionId?: string | null
): Promise<GitLabWorkItemDetails | null> {
// 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 }
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`
],
glabRepoExecOptions(repoPath, connectionId)
),
fetchDiscussions(repoPath, projectRef, 'mr', iid)
fetchDiscussions(repoPath, projectRef, 'mr', iid, connectionId)
])
const mrRaw = JSON.parse(mrRes.stdout) as GitLabRawMR
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
const full = mapMRToWorkItem(mrRaw, projectRef.path)
const full = mapMRToWorkItem(mrRaw, projectRef.path, projectRef)
const { repoId: _repoId, ...rest } = full
return rest
})()
const pipelineId = mrRaw.head_pipeline?.id
const pipelineJobs =
typeof pipelineId === 'number'
? await fetchPipelineJobs(repoPath, projectRef, pipelineId).catch(() => [])
? await fetchPipelineJobs(repoPath, projectRef, pipelineId, connectionId).catch(() => [])
: undefined
return {
item,
@@ -281,23 +268,3 @@ async function fetchMRDetails(
...(pipelineJobs !== undefined ? { pipelineJobs } : {})
}
}
async function fetchMRDetailsFallback(
repoPath: string,
iid: number
): Promise<GitLabWorkItemDetails | null> {
const { stdout } = await glabExecFileAsync(['mr', 'view', String(iid), '--output', 'json'], {
cwd: repoPath
})
const mrRaw = JSON.parse(stdout) as GitLabRawMR
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
const full = mapMRToWorkItem(mrRaw, 'unknown')
const { repoId: _repoId, ...rest } = full
return rest
})()
return {
item,
body: mrRaw.description ?? '',
comments: []
}
}