1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import type {
4 PullRequestCapabilities,
5 PullRequestReaction,
6 PullRequestViewerPermissions,
7} from "@t3tools/contracts";
8
9import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
10import {
11 PullRequestProviderError,
12 type PullRequestProviderFailure,
13 type ProviderChangeRequestActivity,
14 type ProviderChangeRequestDetail,
15 type PullRequestProviderApi,
16} from "@t3tools/source-control-core/server/PullRequestProvider";
17import { definition } from "../client/definition.ts";
18
19const CAPABILITIES: PullRequestCapabilities = {
20 diff: true,
21 comment: true,
22 actions: [...definition.changeRequestActions],
23 // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it.
24 mergeMethods: ["merge", "squash", "rebase"],
25 // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing
26 // that merges the target back in the way GitHub's update button can. Declaring only what it
27 // does is what lets a request to merge the target in be refused instead of quietly rebasing.
28 updateMethods: ["rebase"],
29 search: true,
30 reactions: true,
31 // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside
32 // that browser can read or write them. So the marks made here are this environment's own: they
33 // follow the reader between the clients connected to it, but they are not the ones gitlab.com
34 // shows, and the surface says so rather than implying a review can be carried on from there.
35 viewedFiles: "environment",
36 review: {
37 inlineComment: true,
38 reply: true,
39 resolve: true,
40 // No "changes requested": GitLab has approval and unresolved discussions, and nothing that
41 // says a merge request has been reviewed and rejected.
42 verdicts: ["comment", "approve"],
43 },
44 reviewers: { request: true, listCandidates: true },
45 edit: { changeRequest: true, comment: true },
46};
47
48/**
49 * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to
50 * the target, so it is not literally the same permission — but GitLab reports nothing narrower,
51 * and someone it will not let land this change has no business rewriting its branch either.
52 */
53const MERGE_ACTIONS: ReadonlySet<string> = new Set([
54 "merge",
55 "update-branch",
56 "enable-auto-merge",
57 "disable-auto-merge",
58]);
59
60/**
61 * What the signed-in account may do here. GitLab answers exactly one of these questions per
62 * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing
63 * narrowed.
64 *
65 * The rest stay granted. GitLab's REST API reports the viewer's role on the project but never
66 * whether they opened this merge request — and its author may close it, reopen it and move it in
67 * and out of draft whatever their role, just as the author of a note may resolve the discussion
68 * it started. Withholding those controls from the one person entitled to them is the worse of the
69 * two mistakes, so they are offered and GitLab explains any refusal itself.
70 *
71 * Asking for a review is granted for the same reason: GitLab takes a reviewer set from the author
72 * and from anyone with the Developer role, and states neither of those two facts here.
73 */
74export function gitLabViewerPermissions(input: {
75 readonly viewerCanMerge: boolean;
76}): PullRequestViewerPermissions {
77 return {
78 // Arming the merge and taking the arming back are the merge, deferred, so they answer to
79 // the same `can_merge` the merge itself does.
80 actions: CAPABILITIES.actions.filter(
81 (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge,
82 ),
83 comment: true,
84 resolve: true,
85 verdicts: CAPABILITIES.review.verdicts,
86 requestReviewers: true,
87 ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}),
88 };
89}
90
91/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
92export function gitLabProviderFailure(
93 error: GitLabPullRequestCli.GitLabPullRequestCliError,
94): PullRequestProviderFailure {
95 if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" };
96 if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" };
97 if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" };
98 if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" };
99 return { reason: "failed" };
100}
101
102export const make = Effect.gen(function* () {
103 const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
104
105 const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) =>
106 new PullRequestProviderError({
107 provider: SourceControlProviderKind.make("gitlab"),
108 operation,
109 ...gitLabProviderFailure(error),
110 detail: error.detail,
111 cause: error,
112 });
113
114 const provider: PullRequestProviderApi = {
115 kind: SourceControlProviderKind.make("gitlab"),
116 capabilities: CAPABILITIES,
117
118 getViewer: (input) =>
119 cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))),
120
121 listChangeRequests: (input) =>
122 cli
123 .listMergeRequests({
124 cwd: input.cwd,
125 repository: input.repository,
126 state: input.state,
127 involvement: input.involvement,
128 viewer: input.viewer,
129 limit: input.limit,
130 query: input.query,
131 cursor: input.cursor,
132 })
133 .pipe(
134 Effect.mapError(fail("listChangeRequests")),
135 // GitLab is asked for its merge requests by update, newest first, whether or not it is
136 // being carried on from — so every page it answers is one a cursor can continue.
137 Effect.map((batch) => ({ ...batch, continues: true })),
138 ),
139
140 getChangeRequestChecks: (input) =>
141 cli.getMergeRequestDetail(input).pipe(
142 Effect.map(({ state, checks }) => ({ state, checks })),
143 Effect.mapError(fail("getChangeRequestChecks")),
144 ),
145
146 getChangeRequest: (input) =>
147 Effect.all(
148 [
149 cli.getMergeRequestDetail(input),
150 cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }),
151 ],
152 { concurrency: 2 },
153 ).pipe(
154 Effect.mapError(fail("getChangeRequest")),
155 Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({
156 ...mergeRequest,
157 mergeCapabilities,
158 viewerPermissions: gitLabViewerPermissions(mergeRequest),
159 // A GitLab too old to count the divergence says nothing here rather than "up to
160 // date": the banner is worth missing, and a wrong all-clear is not worth showing.
161 baseComparison:
162 mergeRequest.divergedCommits === undefined
163 ? "unknown"
164 : mergeRequest.divergedCommits > 0
165 ? "behind"
166 : "up-to-date",
167 ...(mergeRequest.divergedCommits === undefined
168 ? {}
169 : { behindBy: mergeRequest.divergedCommits }),
170 })),
171 ),
172
173 getChangeRequestActivity: (input) =>
174 Effect.all(
175 [
176 cli
177 .listNotes(input)
178 .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))),
179 cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])),
180 cli
181 .listDiscussions(input)
182 .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))),
183 // The notes endpoint carries no award of any kind, so they are read alongside it. A
184 // failed read costs the conversation its reactions rather than its words.
185 cli.listReactions(input).pipe(
186 Effect.orElseSucceed(() => ({
187 reactions: [] as ReadonlyArray<PullRequestReaction>,
188 reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
189 })),
190 ),
191 ],
192 { concurrency: 4 },
193 ).pipe(
194 Effect.mapError(fail("getChangeRequestActivity")),
195 Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({
196 reactions: awards.reactions,
197 comments: notes.comments.map((comment) => ({
198 ...comment,
199 reactions: awards.reactionsByNoteId.get(comment.id) ?? [],
200 })),
201 // GitLab reports no count of its own, so the walk's own total is the host's: the
202 // notes endpoint carries every comment on the merge request, including the ones
203 // written under a discussion, and it is read until GitLab runs out.
204 commentCount: notes.comments.length,
205 commentsTruncated: notes.truncated || discussions.truncated,
206 reviewThreads: discussions.threads.map((thread) => ({
207 ...thread,
208 comments: thread.comments.map((comment) => ({
209 ...comment,
210 reactions: awards.reactionsByNoteId.get(comment.id) ?? [],
211 })),
212 })),
213 commits,
214 })),
215 ),
216
217 // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge
218 // request, so there is no cheaper thing to ask GitLab.
219 getViewerPermissions: (input) =>
220 cli
221 .getMergeRequestDetail(input)
222 .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)),
223
224 getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),
225
226 getDiffFileContents: (input) =>
227 cli.getMergeRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))),
228
229 // What each marked file is at the head, which is what tells a mark that still stands from one
230 // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too,
231 // so this stales at the same moment its web UI would.
232 getFileRevisions: (input) =>
233 cli.getFileRevisions(input).pipe(
234 Effect.mapError(fail("getFileRevisions")),
235 Effect.map((revisions) => ({ revisions })),
236 ),
237
238 // Users only: GitLab requests a review of a person, and the groups that can stand in for one
239 // appear in approval rules rather than in a merge request's reviewers.
240 listReviewerCandidates: (input) =>
241 cli
242 .listReviewerCandidates({
243 cwd: input.cwd,
244 repository: input.repository,
245 number: input.number,
246 })
247 .pipe(Effect.mapError(fail("listReviewerCandidates"))),
248
249 setReviewerRequest: (input) =>
250 cli
251 .setReviewerRequest({
252 cwd: input.cwd,
253 repository: input.repository,
254 number: input.number,
255 reviewers: input.reviewers,
256 requested: input.requested,
257 })
258 .pipe(Effect.mapError(fail("setReviewerRequest"))),
259
260 runAction: (input) =>
261 cli
262 .runMergeRequestAction({
263 cwd: input.cwd,
264 repository: input.repository,
265 number: input.number,
266 action: input.action,
267 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
268 })
269 .pipe(Effect.mapError(fail("runAction"))),
270
271 updateChangeRequest: (input) =>
272 cli
273 .updateMergeRequest({
274 cwd: input.cwd,
275 repository: input.repository,
276 number: input.number,
277 ...(input.title === undefined ? {} : { title: input.title }),
278 ...(input.body === undefined ? {} : { description: input.body }),
279 })
280 .pipe(Effect.mapError(fail("updateChangeRequest"))),
281
282 comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))),
283
284 // The kind is not read: every comment this provider hands out, positioned or not, carries a
285 // plain REST note id, and one endpoint rewrites both.
286 updateComment: (input) =>
287 cli
288 .updateNote({
289 cwd: input.cwd,
290 repository: input.repository,
291 number: input.number,
292 noteId: input.commentId,
293 body: input.body,
294 })
295 .pipe(Effect.mapError(fail("updateComment"))),
296
297 submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))),
298
299 replyToThread: (input) =>
300 cli
301 .replyToDiscussion({
302 cwd: input.cwd,
303 repository: input.repository,
304 number: input.number,
305 discussionId: input.threadId,
306 body: input.body,
307 })
308 .pipe(Effect.mapError(fail("replyToThread"))),
309
310 setReaction: (input) =>
311 cli
312 .setReaction({
313 cwd: input.cwd,
314 repository: input.repository,
315 number: input.number,
316 ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }),
317 content: input.content,
318 reacted: input.reacted,
319 })
320 .pipe(Effect.mapError(fail("setReaction"))),
321
322 setThreadResolution: (input) =>
323 cli
324 .setDiscussionResolution({
325 cwd: input.cwd,
326 repository: input.repository,
327 number: input.number,
328 discussionId: input.threadId,
329 resolved: input.resolved,
330 })
331 .pipe(Effect.mapError(fail("setThreadResolution"))),
332 };
333
334 return provider;
335});