From 1228c01a10334e5c272c59215ae4a1394bddd055 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:03:10 +0200 Subject: [PATCH] Find GitLab merge requests and issues by number (#6525) * fix(gitlab): find merge requests and issues by number GitLab text search matches titles and descriptions but not item numbers. Use repository-scoped IID filtering for numeric queries, keeping open-only results and the existing text search behavior. * fix(gitlab): retain text matches in number searches Supplement title and description search with the open-item number lookup, rather than replacing it. Prioritize exact numbers, deduplicate results, and preserve each list limit. * refactor(gitlab): name list helper arguments --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/services/gitlab-service.test.ts | 126 ++++++++++++++++++ .../server/src/services/gitlab-service.ts | 105 ++++++++++----- 2 files changed, 199 insertions(+), 32 deletions(-) diff --git a/packages/server/src/services/gitlab-service.test.ts b/packages/server/src/services/gitlab-service.test.ts index c74224e444..371198e447 100644 --- a/packages/server/src/services/gitlab-service.test.ts +++ b/packages/server/src/services/gitlab-service.test.ts @@ -1328,6 +1328,132 @@ describe("createGitLabService", () => { ); }); + it("finds a merge request by number even when its title and description omit it", async () => { + const { service } = makeService((args) => { + // GitLab text search does not search the IID. Model both API behaviors. + if (args.includes("--search")) return ok("[]"); + if (args[0] === "api" && args[1].includes("iids[]=14")) { + return ok(JSON.stringify([OPEN_MR])); + } + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: " 14 ", + kinds: ["change_request"], + }); + expect(result.items).toEqual([ + expect.objectContaining({ + kind: "change_request", + number: 14, + title: OPEN_MR.title, + headRefName: OPEN_MR.source_branch, + baseRefName: OPEN_MR.target_branch, + }), + ]); + }); + + it("finds issues by number through the same search interface", async () => { + const { service } = makeService((args) => { + if (args.includes("--search")) return ok("[]"); + if (args[0] === "api" && args[1].includes("iids[]=7")) { + return ok(JSON.stringify([OPEN_ISSUE])); + } + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: "7", + kinds: ["issue"], + }); + expect(result.items).toEqual([ + expect.objectContaining({ kind: "issue", number: 7, title: OPEN_ISSUE.title }), + ]); + }); + + it.each([ + { kind: "change_request" as const, item: OPEN_MR }, + { kind: "issue" as const, item: OPEN_ISSUE }, + ])( + "preserves numeric text matches after the exact $kind number, without duplicates", + async ({ kind, item }) => { + const textMatches = [ + { + ...item, + iid: 90, + title: `References ${item.iid}`, + updated_at: "2026-07-01T00:00:00.000Z", + }, + item, + { + ...item, + iid: 91, + description: `References ${item.iid}`, + updated_at: "2026-06-30T00:00:00.000Z", + }, + ]; + const { service } = makeService((args) => { + if (args.includes("--search")) return ok(JSON.stringify(textMatches)); + if (args[0] === "api") return ok(JSON.stringify([item])); + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: String(item.iid), + limit: 2, + kinds: [kind], + }); + expect( + result.items.map((resultItem) => ({ kind: resultItem.kind, number: resultItem.number })), + ).toEqual([ + { kind, number: item.iid }, + { kind, number: 90 }, + ]); + }, + ); + + it.each([ + { kind: "change_request" as const, item: OPEN_MR }, + { kind: "issue" as const, item: OPEN_ISSUE }, + ])("keeps $kind text matches when the queried number is not open", async ({ kind, item }) => { + const { service } = makeService((args) => { + if (args.includes("--search")) { + return ok(JSON.stringify([{ ...item, description: "References 2024" }])); + } + if (args[0] === "api") return ok("[]"); + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: "2024", + kinds: [kind], + }); + expect(result.items.map(({ number }) => number)).toEqual([item.iid]); + }); + + it("keeps closed and missing merge requests out of number search results", async () => { + const closedMr = { ...OPEN_MR, state: "closed" }; + const { service } = makeService((args) => { + if (args.includes("--search")) return ok("[]"); + if (args[0] !== "api") throw new Error(`unexpected glab args: ${args.join(" ")}`); + const endpoint = new URL(args[1], "https://gitlab.example.com/api/v4/"); + const matches = + endpoint.searchParams.get("iids[]") === String(closedMr.iid) && + endpoint.searchParams.get("state") !== "opened"; + return ok(JSON.stringify(matches ? [closedMr] : [])); + }); + for (const query of ["14", "999999"]) { + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query, + limit: 10, + kinds: ["change_request"], + }); + expect(result.authState).toBe("authenticated"); + expect(result.items).toEqual([]); + } + }); + it("searches issues and merge requests and maps them to neutral results", async () => { const { service, calls } = makeService((args) => { if (args[0] === "issue") return ok(JSON.stringify([OPEN_ISSUE])); diff --git a/packages/server/src/services/gitlab-service.ts b/packages/server/src/services/gitlab-service.ts index f0b166cdda..1fff0e2a02 100644 --- a/packages/server/src/services/gitlab-service.ts +++ b/packages/server/src/services/gitlab-service.ts @@ -882,6 +882,31 @@ function throwFirstNonGlabAuthSearchRejection(results: PromiseSettledResult({ + kind, + input, + schema, + }: { + kind: "mr" | "issue"; + input: ListPullRequestsOptions | ListIssuesOptions; + schema: z.ZodType; + }): Promise { + const textSearch = runJson(buildGitLabListArgs({ kind, input }), { cwd: input.cwd }, schema); + const numberQuery = gitLabNumberQuery(input.query); + if (!numberQuery) return textSearch; + + // GitLab text search omits IIDs, so supplement it with an open-item lookup. + const resource = kind === "mr" ? "merge_requests" : "issues"; + const [numberMatches, textMatches] = await Promise.all([ + runJson( + ["api", `projects/:id/${resource}?state=opened&iids[]=${numberQuery}`], + { cwd: input.cwd }, + schema, + ), + textSearch, + ]); + const seen = new Set(); + const items = [...numberMatches, ...textMatches].filter((item) => { + if (seen.has(item.iid)) return false; + seen.add(item.iid); + return true; + }); + return typeof input.limit === "number" ? items.slice(0, input.limit) : items; + } + async function runMergeRequestList( input: ListPullRequestsOptions, ): Promise { - const args = ["mr", "list", "-F", "json"]; - const query = input.query?.trim(); - if (query) { - args.push("--search", query); - } - if (typeof input.limit === "number") { - args.push("-P", String(input.limit)); - } - const mergeRequests = await runJson( - args, - { cwd: input.cwd }, - z.array(GitLabMergeRequestSchema), - ); + const mergeRequests = await runList({ + kind: "mr", + input, + schema: z.array(GitLabMergeRequestSchema), + }); return mergeRequests.map(toPullRequestSummary); } - /** - * `glab issue list` toggles JSON output with `-O/--output` (text|json); its - * `-F/--output-format` flag means something else (details|ids|urls) and - * silently falls back to the human-readable table for an unknown value. This - * differs from `glab mr list`, where `-F/--output` is the JSON toggle. Using - * the wrong flag here would emit a text table that fails JSON parsing. - */ async function runIssueList(input: ListIssuesOptions): Promise { - const args = ["issue", "list", "-O", "json"]; - const query = input.query?.trim(); - if (query) { - args.push("--search", query); - } - if (typeof input.limit === "number") { - args.push("-P", String(input.limit)); - } - const issues = await runJson(args, { cwd: input.cwd }, z.array(GitLabIssueSchema)); + const issues = await runList({ kind: "issue", input, schema: z.array(GitLabIssueSchema) }); return issues.map(toIssueSummary); } @@ -1359,9 +1393,16 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F }); } } - items.sort( - (left, right) => parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt), - ); + const numberQuery = gitLabNumberQuery(input.query); + items.sort((left, right) => { + if (numberQuery) { + const numberMatchOrder = + Number(right.number === Number(numberQuery)) - + Number(left.number === Number(numberQuery)); + if (numberMatchOrder !== 0) return numberMatchOrder; + } + return parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt); + }); return { items,