fix(gitlab): find issues and #N in the new-workspace gitlab source (#32005)

This commit is contained in:
Kelvin Amoaba
2026-10-22 22:36:52 +00:00
committed by GitHub
parent e0bd073930
commit c69e83176d
9 changed files with 186 additions and 111 deletions
+12
View File
@@ -119,6 +119,18 @@ describe('gitlab client — combined listWorkItems', () => {
expect(issuesCallPath.at(-1)).toContain('search=ambiguous%20selector')
})
it('looks up `#N` as the issue iid instead of a text search', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'opened', 1, 20, undefined, ' #23 ')
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
const issueParams = new URLSearchParams(issuesCallPath.at(-1)?.split('?')[1])
expect(issueParams.getAll('iids[]')).toEqual(['23'])
expect(issueParams.has('search')).toBe(false)
})
it('passes the requested page through to merge request and issue fetches', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
@@ -0,0 +1,115 @@
import { listCachedRemoteNames, shouldProbeGitRemote } from '../git/remote-name-listing'
import type { IssueSourcePreference } from '../../shared/repo-types'
import {
getProjectRefForRemote,
type LocalGitExecOptions,
type ProjectRef
} from './gitlab-project-ref-resolution'
export async function getIssueProjectRef(
repoPath: string,
knownHosts?: readonly string[],
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ProjectRef | null> {
const originPromise = getProjectRefForRemote(
repoPath,
'origin',
knownHosts,
connectionId,
localGitOptions
)
if (await shouldProbeGitRemote(repoPath, 'upstream', connectionId, localGitOptions)) {
const upstream = await getProjectRefForRemote(
repoPath,
'upstream',
knownHosts,
connectionId,
localGitOptions
)
if (upstream) {
return upstream
}
}
return (
(await originPromise) ??
getProjectRefForOtherRemote(repoPath, knownHosts, connectionId, localGitOptions)
)
}
// Why: glab infers a project from any GitLab remote, e.g. one named `gitlab`; the issue source should too.
async function getProjectRefForOtherRemote(
repoPath: string,
knownHosts?: readonly string[],
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ProjectRef | null> {
const remotes = await listCachedRemoteNames(repoPath, connectionId, localGitOptions)
const otherRemotes = (remotes ?? []).filter((name) => name !== 'origin' && name !== 'upstream')
// Why: glab ranks a remote named `gitlab` above the rest.
otherRemotes.sort((a, b) => Number(b === 'gitlab') - Number(a === 'gitlab'))
for (const remoteName of otherRemotes) {
const projectRef = await getProjectRefForRemote(
repoPath,
remoteName,
knownHosts,
connectionId,
localGitOptions
)
if (projectRef) {
return projectRef
}
}
return null
}
export type ResolvedIssueSource = {
source: ProjectRef | null
/** True when explicit upstream is gone and resolver fell back to origin. */
fellBack: boolean
}
export async function resolveIssueSource(
repoPath: string,
preference: IssueSourcePreference | undefined,
knownHosts?: readonly string[],
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ResolvedIssueSource> {
if (preference === 'upstream') {
const upstream = await getProjectRefForRemote(
repoPath,
'upstream',
knownHosts,
connectionId,
localGitOptions
)
if (upstream) {
return { source: upstream, fellBack: false }
}
const origin = await getProjectRefForRemote(
repoPath,
'origin',
knownHosts,
connectionId,
localGitOptions
)
return { source: origin, fellBack: origin !== null }
}
if (preference === 'origin') {
return {
source: await getProjectRefForRemote(
repoPath,
'origin',
knownHosts,
connectionId,
localGitOptions
),
fellBack: false
}
}
return {
source: await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions),
fellBack: false
}
}
@@ -1,10 +1,8 @@
import { glabExecFileAsync } from '../git/runner'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
import { shouldProbeGitRemote } from '../git/remote-name-listing'
import { addWslEnvKeys } from '../wsl-env'
import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe'
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache'
import type { IssueSourcePreference } from '../../shared/repo-types'
import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight'
import {
_resetGlabUnauthenticatedHosts,
@@ -171,85 +169,6 @@ export async function getProjectRef(
return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId, localGitOptions)
}
export async function getIssueProjectRef(
repoPath: string,
knownHosts?: readonly string[],
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ProjectRef | null> {
const originPromise = getProjectRefForRemote(
repoPath,
'origin',
knownHosts,
connectionId,
localGitOptions
)
if (await shouldProbeGitRemote(repoPath, 'upstream', connectionId, localGitOptions)) {
const upstream = await getProjectRefForRemote(
repoPath,
'upstream',
knownHosts,
connectionId,
localGitOptions
)
if (upstream) {
return upstream
}
}
return originPromise
}
export type ResolvedIssueSource = {
source: ProjectRef | null
/** True when explicit upstream is gone and resolver fell back to origin. */
fellBack: boolean
}
export async function resolveIssueSource(
repoPath: string,
preference: IssueSourcePreference | undefined,
knownHosts?: readonly string[],
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ResolvedIssueSource> {
if (preference === 'upstream') {
const upstream = await getProjectRefForRemote(
repoPath,
'upstream',
knownHosts,
connectionId,
localGitOptions
)
if (upstream) {
return { source: upstream, fellBack: false }
}
const origin = await getProjectRefForRemote(
repoPath,
'origin',
knownHosts,
connectionId,
localGitOptions
)
return { source: origin, fellBack: origin !== null }
}
if (preference === 'origin') {
return {
source: await getProjectRefForRemote(
repoPath,
'origin',
knownHosts,
connectionId,
localGitOptions
),
fellBack: false
}
}
return {
source: await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions),
fellBack: false
}
}
export function glabRepoExecOptions(
repoPath: string,
connectionId?: string | null,
+23
View File
@@ -135,6 +135,29 @@ describe('gitlab project ref resolution', () => {
expect(gitRemoteGetUrlCalls('origin')).toHaveLength(1)
})
it('finds the GitLab project on other remotes, preferring one named gitlab like glab', async () => {
mockGitRemoteCommands({
origin: 'git@github.com:stablyai/orca.git\n',
'a-fork': 'git@gitlab.com:someone/orca.git\n',
gitlab: 'git@gitlab.com:stablyai/orca.git\n'
})
await expect(getIssueProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'stablyai/orca'
})
})
it('adds no glab probe for extra remotes on a GitHub-only repo', async () => {
mockGitRemoteCommands({
origin: 'git@github.com:stablyai/orca.git\n',
fork: 'git@github.com:someone/orca.git\n'
})
await expect(getIssueProjectRef('/repo')).resolves.toBeNull()
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('does not mix origin and upstream cache entries for the same repo path', async () => {
mockGitRemoteCommands({
origin: 'git@gitlab.com:fork/orca.git\n',
+4 -8
View File
@@ -15,21 +15,17 @@ export {
_resetKnownHostsCache,
_resetProjectRefCache,
getGlabKnownHosts,
getIssueProjectRef,
getProjectRef,
getProjectRefForRemote,
glabHostEnvOptions,
glabHostnameArgs,
glabRepoExecOptions,
parseGlabAuthStatusHosts,
parseGitLabProjectRef,
resolveIssueSource
} from './gitlab-project-ref-resolution'
export type {
LocalGitExecOptions,
ProjectRef,
ResolvedIssueSource
parseGitLabProjectRef
} from './gitlab-project-ref-resolution'
export type { LocalGitExecOptions, ProjectRef } from './gitlab-project-ref-resolution'
export { getIssueProjectRef, resolveIssueSource } from './gitlab-issue-source-resolution'
export type { ResolvedIssueSource } from './gitlab-issue-source-resolution'
export {
parseGlabApiResponse,
parseGlabJsonList,
+11 -1
View File
@@ -148,6 +148,16 @@ export async function listWorkItems(
}
}
function issueSearchParam(query?: string): string {
const trimmed = query?.trim()
if (!trimmed) {
return ''
}
// Why: GitLab's search matches text only, so `#23` (its issue reference) needs the iid filter.
const iid = /^#(\d+)$/.exec(trimmed)?.[1]
return iid ? `&iids[]=${iid}` : `&search=${encodeURIComponent(trimmed)}`
}
export async function fetchIssuesAsWorkItems(
repoPath: string,
projectRef: ProjectRef,
@@ -161,7 +171,7 @@ export async function fetchIssuesAsWorkItems(
await acquire()
try {
const stateParam = state === 'all' ? '' : `&state=${state}`
const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : ''
const searchParam = issueSearchParam(query)
const { stdout } = await glabExecFileAsync(
[
'api',
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from 'react'
import {
listGitLabMRsForSource,
listGitLabWorkItemsForSource,
lookupGitLabWorkItemByPathForSource
} from '@/lib/gitlab-work-item-source-lookup'
import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links'
@@ -97,7 +97,7 @@ export function useSmartWorkspaceGitlabSearch({
shouldQueryGitlab
])
// Why: state chips mirror GitLab's default opened-MR list when no URL is pasted.
// Why: state chips mirror GitLab's default opened list when no URL is pasted.
useEffect(() => {
if (!shouldQueryGitlab || disabled || gitlabEnabled === false) {
if (!shouldQueryGitlab) {
@@ -123,7 +123,7 @@ export function useSmartWorkspaceGitlabSearch({
}
void Promise.all(
repoBackedSearchTargets.map((target) =>
listGitLabMRsForSource({
listGitLabWorkItemsForSource({
repoPath: target.repo.path,
repoId: target.repo.id,
sourceContext: target.gitlabSourceContext,
@@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client'
import {
listGitLabMRsForSource,
listGitLabWorkItemsForSource,
lookupGitLabWorkItemByPathForSource
} from './gitlab-work-item-source-lookup'
import type { GitLabWorkItem, ListMergeRequestsResult } from '../../../shared/gitlab-types'
import type { GitLabPagedResult, GitLabWorkItem } from '../../../shared/gitlab-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
vi.mock('@/runtime/runtime-rpc-client', async () => {
@@ -47,7 +47,7 @@ describe('GitLab source lookup routing', () => {
api: {
gl: {
workItemByPath: vi.fn(),
listMRs: vi.fn()
listWorkItems: vi.fn()
}
}
})
@@ -83,8 +83,8 @@ describe('GitLab source lookup routing', () => {
expect(window.api.gl.workItemByPath).not.toHaveBeenCalled()
})
it('routes runtime-owned GitLab MR lists through runtime RPC', async () => {
const result: ListMergeRequestsResult = {
it('routes runtime-owned GitLab work item lists through runtime RPC', async () => {
const result: GitLabPagedResult<GitLabWorkItem> = {
items: [gitlabItem({ repoId: 'runtime-returned' })],
page: 1,
perPage: 12,
@@ -94,7 +94,7 @@ describe('GitLab source lookup routing', () => {
vi.mocked(callRuntimeRpc).mockResolvedValue(result)
await expect(
listGitLabMRsForSource({
listGitLabWorkItemsForSource({
repoPath: '/workspace/app',
repoId: 'renderer-repo',
sourceContext: runtimeSourceContext,
@@ -109,7 +109,7 @@ describe('GitLab source lookup routing', () => {
// actually filters; previously the field was never threaded through.
expect(callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-1' },
'gitlab.listMRs',
'gitlab.listWorkItems',
expect.objectContaining({
repo: 'id:runtime-repo',
state: 'opened',
@@ -119,16 +119,16 @@ describe('GitLab source lookup routing', () => {
}),
{ timeoutMs: 30_000 }
)
expect(window.api.gl.listMRs).not.toHaveBeenCalled()
expect(window.api.gl.listWorkItems).not.toHaveBeenCalled()
})
it('forwards the search query to local GitLab MR lists over Electron IPC', async () => {
it('forwards the search query to local GitLab work item lists over Electron IPC', async () => {
const localSourceContext: TaskSourceContext = {
...runtimeSourceContext,
hostId: 'local',
repoId: 'local-repo'
}
vi.mocked(window.api.gl.listMRs).mockResolvedValue({
vi.mocked(window.api.gl.listWorkItems).mockResolvedValue({
items: [gitlabItem({ repoId: 'local-returned' })],
page: 1,
perPage: 12,
@@ -137,7 +137,7 @@ describe('GitLab source lookup routing', () => {
})
await expect(
listGitLabMRsForSource({
listGitLabWorkItemsForSource({
repoPath: '/workspace/app',
repoId: 'local-repo',
sourceContext: localSourceContext,
@@ -148,7 +148,7 @@ describe('GitLab source lookup routing', () => {
})
).resolves.toMatchObject({ items: [{ repoId: 'local-repo', number: 7 }] })
expect(window.api.gl.listMRs).toHaveBeenCalledWith({
expect(window.api.gl.listWorkItems).toHaveBeenCalledWith({
repoPath: '/workspace/app',
repoId: 'local-repo',
sourceContext: localSourceContext,
@@ -1,4 +1,4 @@
import type { GitLabWorkItem, ListMergeRequestsResult } from '../../../shared/gitlab-types'
import type { GitLabPagedResult, GitLabWorkItem } from '../../../shared/gitlab-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { gitLabApiFor } from '@/runtime/gitlab-owner-api'
@@ -15,7 +15,7 @@ type GitLabWorkItemByPathLookupArgs = GitLabSourceLookupArgs & {
type: 'issue' | 'mr'
}
type GitLabMRListLookupArgs = GitLabSourceLookupArgs & {
type GitLabWorkItemListLookupArgs = GitLabSourceLookupArgs & {
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
@@ -33,10 +33,10 @@ export async function lookupGitLabWorkItemByPathForSource(
return item ? withRendererRepoId(item, args.repoId) : null
}
export async function listGitLabMRsForSource(
args: GitLabMRListLookupArgs
): Promise<ListMergeRequestsResult> {
const result = await gitLabApiFor(args).listMRs(args)
export async function listGitLabWorkItemsForSource(
args: GitLabWorkItemListLookupArgs
): Promise<GitLabPagedResult<GitLabWorkItem>> {
const result = await gitLabApiFor(args).listWorkItems(args)
return {
...result,
items: result.items.map((item) => withRendererRepoId(item, args.repoId))