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>
This commit is contained in:
paseo-bot[bot]
2026-10-11 00:03:10 +02:00
committed by GitHub
parent 563c014dc1
commit 1228c01a10
2 changed files with 199 additions and 32 deletions
@@ -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]));
+73 -32
View File
@@ -882,6 +882,31 @@ function throwFirstNonGlabAuthSearchRejection(results: PromiseSettledResult<unkn
}
}
function buildGitLabListArgs({
kind,
input,
}: {
kind: "mr" | "issue";
input: ListPullRequestsOptions | ListIssuesOptions;
}): string[] {
const query = input.query?.trim();
// issue list uses -O for JSON; its -F flag controls details/ids/urls.
// mr list uses -F for JSON instead.
const args = [kind, "list", kind === "mr" ? "-F" : "-O", "json"];
if (query) {
args.push("--search", query);
}
if (typeof input.limit === "number") {
args.push("-P", String(input.limit));
}
return args;
}
function gitLabNumberQuery(query: string | undefined): string | null {
const trimmed = query?.trim();
return trimmed && /^\d+$/.test(trimmed) ? trimmed : null;
}
export function createGitLabService(options: CreateGitLabServiceOptions = {}): ForgeService {
const runner = options.runner ?? runGlabCommand;
const resolveGlab = createCachedCliPathResolver(options.resolveGlabPath ?? resolveGlabPath);
@@ -1057,42 +1082,51 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
}
}
async function runList<T extends { iid: number }>({
kind,
input,
schema,
}: {
kind: "mr" | "issue";
input: ListPullRequestsOptions | ListIssuesOptions;
schema: z.ZodType<T[]>;
}): Promise<T[]> {
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<number>();
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<PullRequestSummary[]> {
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<IssueSummary[]> {
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,