Skip to content
41 changes: 41 additions & 0 deletions packages/source-control-gitlab/src/server/GitLabCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("{}")));
Expand Down
94 changes: 57 additions & 37 deletions packages/source-control-gitlab/src/server/GitLabCli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"),
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}),
readChangeRequestTemplate: Effect.fn("GitLabSourceControlProvider.readChangeRequestTemplate")(
function ({ cwd, treeish }) {
return GitLabChangeRequestTemplate.detect(cwd, treeish, git.execute);
Expand Down
Loading