diff --git a/packages/source-control-gitlab/src/server/GitLabCli.test.ts b/packages/source-control-gitlab/src/server/GitLabCli.test.ts index d99a8983d8620..e5257991cbf4a 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.test.ts @@ -166,6 +166,47 @@ layer("GitLabCli.layer", (it) => { }), ); + it.effect( + "retries oversized MR lists with smaller pages without losing same-named branches", + () => + Effect.gen(function* () { + const entries = Array.from({ length: 4 }, (_, index) => ({ + iid: index + 1, + title: "MR " + index, + // The first alone escapes past the default 1 MB cap; together they pass the list cap. + description: index === 0 ? '"'.repeat(600_000) : "x".repeat(3_000_000), + web_url: "https://gitlab.com/acme/web/-/merge_requests/" + (index + 1), + target_branch: "main", + source_branch: "feature", + source_project_id: index === 3 ? 100 : 101, + target_project_id: 100, + })); + mockedRun.mockImplementation((input) => { + const size = Number(input.args[input.args.indexOf("--per-page") + 1]); + const pageIndex = input.args.indexOf("--page"); + const page = pageIndex < 0 ? 1 : Number(input.args[pageIndex + 1]); + const raw = JSON.stringify(entries.slice((page - 1) * size, page * size)); + const cap = input.maxOutputBytes ?? 1_000_000; + return Effect.succeed({ + ...processOutput(raw.slice(0, cap)), + stdoutTruncated: raw.length > cap, + }); + }); + const glab = yield* GitLabCli.GitLabCli; + const result = yield* glab.listMergeRequests({ + cwd: "/repo", + headSelector: "feature", + state: "open", + limit: 4, + }); + assert.deepStrictEqual( + result.map((mr) => mr.number), + [1, 2, 3, 4], + ); + assert.strictEqual(result[3]?.isCrossRepository, false); + }), + ); + it.effect("creates same-project MRs without a repository lookup", () => Effect.gen(function* () { mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts index fbc995441b6f9..ffa6d701b221d 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Match from "effect/Match"; import * as Option from "effect/Option"; @@ -22,6 +23,11 @@ import { import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; const DEFAULT_TIMEOUT_MS = 30_000; +// GitLab allows ~1M-character descriptions; glab's JSON escaping (`\u003c`) can grow one sixfold. +const MERGE_REQUEST_LIST_MAX_BYTES = 8_000_000; +const decodeJsonArray = Schema.decodeUnknownExit( + Schema.fromJsonString(Schema.Array(Schema.Unknown)), +); const gitLabCliExecutionErrorContext = { operation: Schema.Literal("execute"), @@ -481,43 +487,57 @@ export const make = Effect.gen(function* () { return GitLabCli.of({ execute, - listMergeRequests: Effect.fn("GitLabCli.listMergeRequests")(function (input) { - return execute({ - cwd: input.cwd, - args: [ - "mr", - "list", - "--source-branch", - sourceRefName(input), - ...stateArgs(input.state), - "--per-page", - String(input.limit ?? 20), - "--output", - "json", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - raw.length === 0 - ? Effect.succeed([]) - : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestListDecodeError({ - operation: "listMergeRequests", - command: "glab", - cwd: input.cwd, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt)); - }), - ), - ), - ); + listMergeRequests: Effect.fn("GitLabCli.listMergeRequests")(function* (input) { + const limit = input.limit ?? 20; + let pageSize = limit; + let page = 1; + const items: GitLabMergeRequestSummary[] = []; + while (items.length < limit) { + const result = yield* execute({ + cwd: input.cwd, + args: [ + "mr", + "list", + "--source-branch", + sourceRefName(input), + ...stateArgs(input.state), + "--per-page", + String(pageSize), + ...(page > 1 ? ["--page", String(page)] : []), + "--output", + "json", + ], + maxOutputBytes: MERGE_REQUEST_LIST_MAX_BYTES, + }); + if (result.stdoutTruncated && pageSize > 1) { + pageSize = Math.max(1, Math.floor(pageSize / 2)); + page = 1; + items.length = 0; + continue; + } + const raw = result.stdout.trim(); + if (raw.length === 0) break; + const decoded = decodeGitLabMergeRequestListJson(raw); + if (result.stdoutTruncated || !Result.isSuccess(decoded)) { + return yield* new GitLabMergeRequestListDecodeError({ + operation: "listMergeRequests", + command: "glab", + cwd: input.cwd, + cause: Result.isFailure(decoded) ? decoded.failure : "MR list output was truncated", + }); + } + items.push(...decoded.success.map(toSummaryWithOptionalUpdatedAt)); + // Count raw entries: the decoder drops invalid ones, which would end paging early. + const entries = decodeJsonArray(raw); + if ( + page * pageSize >= limit || + (Exit.isSuccess(entries) && entries.value.length < pageSize) + ) { + break; + } + page += 1; + } + return items.slice(0, limit); }), getMergeRequest: Effect.fn("GitLabCli.getMergeRequest")(function (input) { return executeMergeRequest({ diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index 08a301d11c739..bc503c9db46a5 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -199,6 +199,21 @@ selfhosted ); }); +it.effect( + "looks up a branch's merge requests a full page at a time, past forks' same-named branches", + () => + Effect.gen(function* () { + const provider = yield* makeProvider({}); + assert.deepStrictEqual( + provider.headBranchProbe?.({ + headSelectors: ["octocat:feature", "feature"], + state: "open", + }), + { headSelectors: ["feature"], limit: 100 }, + ); + }), +); + it.effect.each(["merge_requests", "issues"])( "resolves %s subjects on the linked host without using the checkout", (kind) => diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index 9f599794057fc..d9c29c1ff39c4 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -210,6 +210,11 @@ export const make = Effect.gen(function* () { return SourceControlProvider.SourceControlProvider.of({ kind: SourceControlProviderKind.make("gitlab"), + // GitLab lists fork merge requests by source branch with no owner filter, so read a full page. + headBranchProbe: ({ headSelectors }) => ({ + headSelectors: headSelectors.filter((selector) => !selector.includes(":")), + limit: 100, + }), readChangeRequestTemplate: Effect.fn("GitLabSourceControlProvider.readChangeRequestTemplate")( function ({ cwd, treeish }) { return GitLabChangeRequestTemplate.detect(cwd, treeish, git.execute);