1import { assert, describe, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
6import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts";
7
8describe("gitLabViewerPermissions", () => {
9 it("offers everything to a viewer GitLab says can merge", () => {
10 expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({
11 // Arming a merge for later and taking the arming back answer to the same `can_merge`.
12 actions: [
13 "merge",
14 "ready",
15 "draft",
16 "close",
17 "reopen",
18 "update-branch",
19 "enable-auto-merge",
20 "disable-auto-merge",
21 ],
22 comment: true,
23 resolve: true,
24 verdicts: ["comment", "approve"],
25 // GitLab says nothing about who may set a reviewer, and an unreported permission is granted.
26 requestReviewers: true,
27 // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so
28 // offering the choice would be offering something no request could carry out.
29 updateMethods: ["rebase"],
30 });
31 });
32
33 it("keeps merge, now and later, from a viewer GitLab says cannot", () => {
34 // `user.can_merge` already accounts for the role, the approval rules and a protected target
35 // branch, so it is the one answer here that does not have to be inferred.
36 expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({
37 actions: ["ready", "draft", "close", "reopen"],
38 comment: true,
39 resolve: true,
40 verdicts: ["comment", "approve"],
41 requestReviewers: true,
42 });
43 });
44
45 it("names no way of updating a branch it will not let this viewer update", () => {
46 // The action and the strategy behind it go together: a button offered with nothing to press
47 // it with, or a strategy left standing next to a withheld button, is a half-refusal.
48 expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined();
49 });
50
51 it("treats an author with read access as any other reader, which is all GitLab says", () => {
52 // Its REST API names no relationship between the viewer and the merge request beyond
53 // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken
54 // from the one person entitled to them.
55 expect(gitLabViewerPermissions({ viewerCanMerge: false }).actions).toEqual([
56 "ready",
57 "draft",
58 "close",
59 "reopen",
60 ]);
61 });
62});
63
64describe("getChangeRequest base freshness", () => {
65 const detail = {
66 number: 7,
67 title: "Merge request 7",
68 url: "https://gitlab.com/acme/web/-/merge_requests/7",
69 author: null,
70 headBranch: "feat/page",
71 baseBranch: "main",
72 state: "open" as const,
73 isDraft: false,
74 mergeability: "mergeable" as const,
75 additions: 0,
76 deletions: 0,
77 createdAt: "2026-07-01T00:00:00Z",
78 updatedAt: "2026-07-02T00:00:00Z",
79 reviewRequestLogins: [],
80 labels: [],
81 body: "",
82 changedFiles: 1,
83 mergedAt: null,
84 closedAt: null,
85 reviewers: [],
86 checks: [],
87 viewerCanMerge: true,
88 reviewerIds: [],
89 };
90
91 it.effect("reads checks without fetching project settings", () =>
92 Effect.gen(function* () {
93 const provider = yield* make.pipe(
94 Effect.provide(
95 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
96 getMergeRequestDetail: () => Effect.succeed(detail),
97 }),
98 ),
99 );
100 const read = provider.getChangeRequestChecks;
101 if (read === undefined) return yield* Effect.die("checks read missing");
102 expect(
103 yield* read({ cwd: "/w", repository: "group/subgroup/web", host: "gitlab.com", number: 7 }),
104 ).toEqual({ state: "open", checks: [] });
105 }),
106 );
107
108 const readWith = (divergence: { readonly divergedCommits?: number }) =>
109 Effect.gen(function* () {
110 const provider = yield* make;
111 return yield* provider.getChangeRequest({
112 cwd: "/w",
113 repository: "acme/web",
114 host: "gitlab.com",
115 number: 7,
116 });
117 }).pipe(
118 Effect.provide(
119 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
120 getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }),
121 getProjectMergeCapabilities: () =>
122 Effect.succeed({ merge: true, squash: true, rebase: true }),
123 }),
124 ),
125 );
126
127 it.effect("reads a counted divergence as a branch that has fallen behind", () =>
128 Effect.gen(function* () {
129 const changeRequest = yield* readWith({ divergedCommits: 3 });
130
131 expect(changeRequest.baseComparison).toBe("behind");
132 expect(changeRequest.behindBy).toBe(3);
133 }),
134 );
135
136 it.effect("reads a divergence of none as a branch that is current", () =>
137 Effect.gen(function* () {
138 const changeRequest = yield* readWith({ divergedCommits: 0 });
139
140 expect(changeRequest.baseComparison).toBe("up-to-date");
141 expect(changeRequest.behindBy).toBe(0);
142 }),
143 );
144
145 it.effect("says nothing at all where GitLab counted nothing", () =>
146 Effect.gen(function* () {
147 // An install too old to answer has to leave the page silent rather than let it claim the
148 // branch is current, which is the one wrong thing this banner could say.
149 const changeRequest = yield* readWith({});
150
151 expect(changeRequest.baseComparison).toBe("unknown");
152 expect(changeRequest.behindBy).toBeUndefined();
153 }),
154 );
155});
156
157describe("rewriting what has already been said", () => {
158 const updateMergeRequest = vi.fn(() => Effect.void);
159 const updateNote = vi.fn(() => Effect.void);
160
161 const providerWith = make.pipe(
162 Effect.provide(
163 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ updateMergeRequest, updateNote }),
164 ),
165 );
166
167 it.effect("sends only the half of the merge request the reader rewrote", () =>
168 Effect.gen(function* () {
169 const provider = yield* providerWith;
170 assert.isDefined(provider.updateChangeRequest);
171
172 yield* provider.updateChangeRequest({
173 cwd: "/w",
174 repository: "acme/web",
175 host: "gitlab.com",
176 number: 7,
177 body: "What this changes.",
178 });
179
180 // GitLab calls it the description, and the title stays out of the request entirely.
181 expect(updateMergeRequest).toHaveBeenCalledWith({
182 cwd: "/w",
183 repository: "acme/web",
184 number: 7,
185 description: "What this changes.",
186 });
187 }),
188 );
189
190 it.effect("rewrites a positioned comment through the same note as any other", () =>
191 Effect.gen(function* () {
192 const provider = yield* providerWith;
193 assert.isDefined(provider.updateComment);
194
195 yield* provider.updateComment({
196 cwd: "/w",
197 repository: "acme/web",
198 host: "gitlab.com",
199 number: 7,
200 commentId: "42",
201 kind: "review-comment",
202 body: "Reworded.",
203 });
204
205 expect(updateNote).toHaveBeenCalledWith({
206 cwd: "/w",
207 repository: "acme/web",
208 number: 7,
209 noteId: "42",
210 body: "Reworded.",
211 });
212 }),
213 );
214});
215
216describe("expanding unchanged lines", () => {
217 const change = {
218 cwd: "/w",
219 repository: "acme/web",
220 host: "gitlab.com",
221 number: 7,
222 changeType: "change" as const,
223 oldPath: "src/page.ts",
224 newPath: "src/page.ts",
225 };
226
227 it.effect("reads both sides of a file through the merge request or one of its commits", () =>
228 Effect.gen(function* () {
229 const getMergeRequestDiffFileContents = vi.fn(() =>
230 Effect.succeed({ oldContents: "before\n", newContents: "after\n" }),
231 );
232 const provider = yield* make.pipe(
233 Effect.provide(
234 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
235 getMergeRequestDiffFileContents,
236 }),
237 ),
238 );
239 assert.isDefined(provider.getDiffFileContents);
240 const oneCommit = { ...change, commit: "a1b2c3d" };
241
242 expect(yield* provider.getDiffFileContents(change)).toEqual({
243 oldContents: "before\n",
244 newContents: "after\n",
245 });
246 yield* provider.getDiffFileContents(oneCommit);
247 expect(getMergeRequestDiffFileContents).toHaveBeenNthCalledWith(1, change);
248 expect(getMergeRequestDiffFileContents).toHaveBeenNthCalledWith(2, oneCommit);
249 }),
250 );
251
252 it.effect("reports a file GitLab cannot expand as a failed read", () =>
253 Effect.gen(function* () {
254 const provider = yield* make.pipe(
255 Effect.provide(
256 Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
257 getMergeRequestDiffFileContents: () =>
258 Effect.fail(
259 new GitLabPullRequestCli.GitLabDiffFileContentsUnavailableError({
260 command: "glab",
261 cwd: "/w",
262 path: "assets/logo.png",
263 reason: "binary",
264 }),
265 ),
266 }),
267 ),
268 );
269 assert.isDefined(provider.getDiffFileContents);
270
271 expect(yield* Effect.flip(provider.getDiffFileContents(change))).toMatchObject({
272 _tag: "PullRequestProviderError",
273 provider: "gitlab",
274 operation: "getDiffFileContents",
275 reason: "failed",
276 detail: "The diff file 'assets/logo.png' is binary.",
277 });
278 }),
279 );
280});