mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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]));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user