Skip to content
Merged
87 changes: 85 additions & 2 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { SourceControlProviderKind } from "@t3tools/contracts";
import { SourceControlProviderKind, VcsProcessExitError } from "@t3tools/contracts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeV8 from "node:v8";
import * as NodeVM from "node:vm";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import * as KeyValueStore from "effect/persistence/KeyValueStore";
import { assert, it } from "@effect/vitest";
import { assert, expect, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as Deferred from "effect/Deferred";
Expand Down Expand Up @@ -34,6 +34,9 @@ import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.t
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli";
import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli";
import * as GitLabPullRequestProvider from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider";
import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
Expand Down Expand Up @@ -7856,6 +7859,86 @@ it.effect.each([
}),
);

it.effect("pauses uncached GitLab activity after only the reactions endpoint is rate limited", () =>
Effect.gen(function* () {
let commandCalls = 0;
const provider = yield* GitLabPullRequestProvider.make.pipe(
Effect.provide(
GitLabPullRequestCli.layer.pipe(
Layer.provide(GitLabCli.layer),
Layer.provide(
TestSourceControlHost.layer({
process: {
run: (input) =>
Effect.suspend(() => {
commandCalls += 1;
return input.args[1] === "graphql"
? Effect.fail(
new VcsProcessExitError({
operation: input.operation,
command: input.command,
cwd: input.cwd,
exitCode: 1,
detail: "API rate limit exceeded.",
failureKind: "rate-limited",
}),
)
: Effect.succeed(TestSourceControlHost.processOutput("[]"));
}),
},
}),
),
),
),
);
const service = yield* makeService({
projects: [
project({
id: "p1",
title: "web",
workspaceRoot: "/repo",
repository: "acme/web",
provider: "gitlab",
host: "gitlab.example.test",
}),
],
providers: [provider],
});
const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };

expect(yield* Effect.result(service.activity(reference))).toMatchObject({
_tag: "Failure",
failure: {
_tag: "PullRequestOperationError",
cause: {
_tag: "PullRequestProviderError",
reason: "rate-limited",
cause: { _tag: "GitLabCliRateLimitError" },
},
},
});
const firstCommandCalls = commandCalls;
assert.isAbove(firstCommandCalls, 0);

// A different merge request cannot reuse the first request's cached failure.
expect(yield* Effect.result(service.activity({ ...reference, number: 2 }))).toMatchObject({
_tag: "Failure",
failure: {
_tag: "PullRequestOperationError",
cause: {
_tag: "PullRequestProviderError",
reason: "rate-limited",
cause: {
_tag: "SourceControlRateLimitPausedError",
host: "gitlab.example.test",
},
},
},
});
assert.strictEqual(commandCalls, firstCommandCalls);
}),
);

it.effect("expands only files the change request changes, across every slice of its diff", () =>
Effect.gen(function* () {
const read: Array<string> = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { assert, describe, expect, it, vi } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as GitLabCli from "./GitLabCli.ts";
import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts";

Expand Down Expand Up @@ -264,3 +265,103 @@ describe("expanding unchanged lines", () => {
}),
);
});

describe("optional activity reads", () => {
const reference = { cwd: "/w", repository: "acme/web", host: "gitlab.com", number: 7 };
const comment = {
id: "42",
kind: "issue-comment" as const,
author: null,
body: "Keep this comment",
createdAt: "2026-07-01T00:00:00Z",
url: null,
path: null,
reviewState: null,
};
const commit = {
oid: "abc",
messageHeadline: "Keep this commit",
committedDate: "2026-07-01T00:00:00Z",
};
const reads = {
listNotes: () => Effect.succeed({ comments: [comment], truncated: false }),
listCommits: () => Effect.succeed([commit]),
listDiscussions: () => Effect.succeed({ threads: [], truncated: false }),
listReactions: () => Effect.succeed({ reactions: [], reactionsByNoteId: new Map() }),
};
const context = {
operation: "execute" as const,
command: "glab" as const,
cwd: "/w",
cause: null,
};

it.effect.each([
{
operation: "listNotes",
error: new GitLabCli.GitLabCliAuthenticationError(context),
reason: "unauthenticated",
},
{
operation: "listCommits",
error: new GitLabCli.GitLabCliUnavailableError(context),
reason: "missing-tool",
},
] as const)(
"reports $reason instead of successful partial activity",
({ operation, error, reason }) =>
Effect.gen(function* () {
const provider = yield* make.pipe(
Effect.provide(
Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
...reads,
[operation]: () => Effect.fail(error),
}),
),
);

expect(yield* Effect.result(provider.getChangeRequestActivity(reference))).toMatchObject({
_tag: "Failure",
failure: {
_tag: "PullRequestProviderError",
provider: "gitlab",
operation: "getChangeRequestActivity",
reason,
detail: error.detail,
cause: error,
},
});
}),
);

it.effect("keeps comments and commits when optional discussions and reactions fail", () =>
Effect.gen(function* () {
const provider = yield* make.pipe(
Effect.provide(
Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
...reads,
listDiscussions: () =>
Effect.fail(
new GitLabPullRequestCli.GitLabMergeRequestReadError({
command: "glab",
cwd: "/w",
operation: "listDiscussions",
cause: null,
}),
),
listReactions: () => Effect.fail(new GitLabCli.GitLabCliCommandError(context)),
}),
),
);

expect(yield* provider.getChangeRequestActivity(reference)).toEqual({
comments: [{ ...comment, reactions: [] }],
commentCount: 1,
commentsTruncated: true,
reviewThreads: [],
reactions: [],
commits: [commit],
});
}),
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ export const make = Effect.gen(function* () {
cause: error,
});

// Optional endpoints may lose part of the activity, but an unusable CLI or account must
// reach the caller so it can report authentication failures and pause a throttled host.
const recoverRead = <A>(
read: Effect.Effect<A, GitLabPullRequestCli.GitLabPullRequestCliError>,
fallback: A,
) =>
read.pipe(
Effect.catch((error) => {
const { reason } = gitLabProviderFailure(error);
return reason === "rate-limited" ||
reason === "unauthenticated" ||
reason === "missing-tool"
? Effect.fail(error)
: Effect.succeed(fallback);
}),
);

const provider: PullRequestProviderApi = {
kind: SourceControlProviderKind.make("gitlab"),
capabilities: CAPABILITIES,
Expand Down Expand Up @@ -155,19 +172,13 @@ export const make = Effect.gen(function* () {
getChangeRequestActivity: (input) =>
Effect.all(
[
cli
.listNotes(input)
.pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))),
cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])),
cli
.listDiscussions(input)
.pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))),
cli.listReactions(input).pipe(
Effect.orElseSucceed(() => ({
reactions: [] as ReadonlyArray<PullRequestReaction>,
reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
})),
),
recoverRead(cli.listNotes(input), { comments: [], truncated: true }),
recoverRead(cli.listCommits(input), []),
recoverRead(cli.listDiscussions(input), { threads: [], truncated: true }),
recoverRead(cli.listReactions(input), {
reactions: [] as ReadonlyArray<PullRequestReaction>,
reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
}),
],
{ concurrency: 4 },
).pipe(
Expand Down
Loading