| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import type { |
| 4 | PullRequestCapabilities, |
| 5 | PullRequestReaction, |
| 6 | PullRequestViewerPermissions, |
| 7 | } from "@t3tools/contracts"; |
| 8 | |
| 9 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; |
| 10 | import { |
| 11 | PullRequestProviderError, |
| 12 | type PullRequestProviderFailure, |
| 13 | type ProviderChangeRequestActivity, |
| 14 | type ProviderChangeRequestDetail, |
| 15 | type PullRequestProviderApi, |
| 16 | } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 17 | import { definition } from "../client/definition.ts"; |
| 18 | |
| 19 | const 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 | */ |
| 53 | const 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 | */ |
| 74 | export 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. */ |
| 92 | export 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 | |
| 102 | export 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 | }); |