mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
@@ -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'
|
||||
],
|
||||
{}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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` ────────────────────
|
||||
|
||||
@@ -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
@@ -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[] = []
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user