1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
6import type {
7 PullRequestAction,
8 PullRequestComment,
9 PullRequestCommit,
10 PullRequestInvolvement,
11 PullRequestListState,
12 PullRequestMergeCapabilities,
13 PullRequestMergeMethod,
14 PullRequestReaction,
15 PullRequestReactionContent,
16 PullRequestReviewCommentDraft,
17 PullRequestReviewPosition,
18 PullRequestReviewThread,
19 PullRequestReviewVerdict,
20 PullRequestReviewerCandidateList,
21} from "@t3tools/contracts";
22
23import * as GitLabCli from "./GitLabCli.ts";
24import {
25 AWARD_EMOJI_GRAPHQL_QUERY,
26 decodeAwardEmojiJson,
27 decodeCommitDiffRefsJson,
28 decodeCommitsJson,
29 decodeDiffRefsJson,
30 decodeDiscussionsJson,
31 decodeMergeRequestDetailJson,
32 decodeMergeRequestDiffsJson,
33 decodeMergeRequestListJson,
34 decodeNotesJson,
35 decodeOwnAwardIdJson,
36 decodeProjectMergeCapabilitiesJson,
37 decodeProjectUsersJson,
38 decodeRepositoryBlobsJson,
39 decodeViewerJson,
40 gitLabAwardName,
41 REPOSITORY_BLOBS_GRAPHQL_QUERY,
42 type GitLabDiffRefs,
43 type GitLabMergeRequestDetail,
44 type GitLabMergeRequestListItem,
45 type GitLabProjectUsers,
46} from "./gitLabMergeRequestJson.ts";
47import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider";
48
49/**
50 * Names the read that produced unusable output, so a failure reports the call it came from
51 * rather than borrowing another operation's message.
52 */
53export class GitLabMergeRequestReadError extends Schema.TaggedError<GitLabMergeRequestReadError>()(
54 "GitLabMergeRequestReadError",
55 {
56 command: Schema.Literal("glab"),
57 cwd: Schema.String,
58 operation: Schema.String,
59 cause: Schema.Defect(),
60 },
61) {
62 get detail(): string {
63 return `GitLab CLI returned an unreadable ${this.operation} response.`;
64 }
65
66 override get message(): string {
67 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
68 }
69}
70
71/** Not a decode failure: glab answered, the account it answered for just has no username. */
72export class GitLabViewerUnavailableError extends Schema.TaggedError<GitLabViewerUnavailableError>()(
73 "GitLabViewerUnavailableError",
74 {
75 command: Schema.Literal("glab"),
76 cwd: Schema.String,
77 },
78) {
79 get detail(): string {
80 return "GitLab CLI returned no username for the authenticated account.";
81 }
82
83 override get message(): string {
84 return `GitLab CLI failed in getViewerUsername: ${this.detail}`;
85 }
86}
87
88/** Not a decode failure: GitLab answered, the merge request just has no revisions to place a
89 * comment against. */
90export class GitLabDiffRefsUnavailableError extends Schema.TaggedError<GitLabDiffRefsUnavailableError>()(
91 "GitLabDiffRefsUnavailableError",
92 {
93 command: Schema.Literal("glab"),
94 cwd: Schema.String,
95 number: Schema.Int,
96 },
97) {
98 get detail(): string {
99 return "The merge request reported no diff revisions.";
100 }
101
102 override get message(): string {
103 return `GitLab CLI failed in getDiffRefs: ${this.detail}`;
104 }
105}
106
107/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */
108export class GitLabDiffCursorError extends Schema.TaggedError<GitLabDiffCursorError>()(
109 "GitLabDiffCursorError",
110 {
111 command: Schema.Literal("glab"),
112 cwd: Schema.String,
113 },
114) {
115 get detail(): string {
116 return "The diff cursor was not one this merge request handed out.";
117 }
118
119 override get message(): string {
120 return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`;
121 }
122}
123
124/** Not a decode failure: the reader named a commit that is not a sha this project could hold. */
125export class GitLabDiffCommitError extends Schema.TaggedError<GitLabDiffCommitError>()(
126 "GitLabDiffCommitError",
127 {
128 command: Schema.Literal("glab"),
129 cwd: Schema.String,
130 },
131) {
132 get detail(): string {
133 return "The named commit was not a commit sha.";
134 }
135
136 override get message(): string {
137 return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`;
138 }
139}
140
141/** The commit exists and decoded, but it has no parent to use as the old revision. */
142export class GitLabDiffCommitParentUnavailableError extends Schema.TaggedError<GitLabDiffCommitParentUnavailableError>()(
143 "GitLabDiffCommitParentUnavailableError",
144 {
145 command: Schema.Literal("glab"),
146 cwd: Schema.String,
147 commit: Schema.String,
148 },
149) {
150 get detail(): string {
151 return `Commit ${this.commit} reported no parent revision.`;
152 }
153
154 override get message(): string {
155 return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`;
156 }
157}
158
159/** A blob exists, but expanding it would be unsafe or would not produce text. */
160export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedError<GitLabDiffFileContentsUnavailableError>()(
161 "GitLabDiffFileContentsUnavailableError",
162 {
163 command: Schema.Literal("glab"),
164 cwd: Schema.String,
165 path: Schema.String,
166 reason: Schema.Literals(["oversized", "binary"]),
167 },
168) {
169 get detail(): string {
170 return this.reason === "oversized"
171 ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.`
172 : `The diff file '${this.path}' is binary.`;
173 }
174
175 override get message(): string {
176 return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`;
177 }
178}
179
180export type GitLabPullRequestCliError =
181 | GitLabCli.GitLabCliError
182 | GitLabMergeRequestReadError
183 | GitLabDiffCursorError
184 | GitLabDiffCommitError
185 | GitLabDiffCommitParentUnavailableError
186 | GitLabDiffFileContentsUnavailableError
187 | GitLabDiffRefsUnavailableError
188 | GitLabViewerUnavailableError;
189
190/** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */
191const MAX_PAGE_SIZE = 100;
192/** Commit history is read one page deep; the rest of a long history stays on GitLab. */
193const COMMIT_PAGE_SIZE = 100;
194/**
195 * Pages of the conversation to follow before it is reported as truncated. GitLab caps a page at
196 * a hundred, so this is a thousand notes and a thousand discussions — more than any merge
197 * request a person is reading holds, and a walk that ends whatever the host has.
198 */
199const CONVERSATION_PAGES = 10;
200const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
201const DIFF_TIMEOUT_MS = 60_000;
202const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
203
204export interface GitLabMergeRequestListBatch {
205 readonly items: ReadonlyArray<GitLabMergeRequestListItem>;
206 readonly truncated: boolean;
207 /** Raw GitLab rows consumed to produce this page, including malformed rows. */
208 readonly cursorAdvance: number;
209}
210
211export interface GitLabMergeRequestDiffSlice {
212 readonly patch: string;
213 /** Files in this slice had their hunks withheld, as opposed to there being more slices. */
214 readonly truncated: boolean;
215 /** Where the next slice starts, or null once the patch is whole. */
216 readonly nextCursor: string | null;
217}
218
219export class GitLabPullRequestCli extends Context.Service<
220 GitLabPullRequestCli,
221 {
222 readonly getViewerUsername: (input: {
223 readonly cwd: string;
224 }) => Effect.Effect<string, GitLabPullRequestCliError>;
225
226 readonly listMergeRequests: (input: {
227 readonly cwd: string;
228 readonly repository: string;
229 readonly state: PullRequestListState;
230 readonly involvement: PullRequestInvolvement;
231 readonly viewer: string;
232 readonly limit: number;
233 /** Free text for GitLab's own `search`, which matches title and description. */
234 readonly query?: string | undefined;
235 /** Where to carry on from in GitLab's stable update-ordered row set. */
236 readonly cursor?: ProviderListCursor | undefined;
237 }) => Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError>;
238
239 readonly getMergeRequestDetail: (input: {
240 readonly cwd: string;
241 readonly repository: string;
242 readonly number: number;
243 }) => Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError>;
244
245 readonly listNotes: (input: {
246 readonly cwd: string;
247 readonly repository: string;
248 readonly number: number;
249 }) => Effect.Effect<
250 { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean },
251 GitLabPullRequestCliError
252 >;
253
254 readonly listCommits: (input: {
255 readonly cwd: string;
256 readonly repository: string;
257 readonly number: number;
258 }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, GitLabPullRequestCliError>;
259
260 readonly getMergeRequestDiff: (input: {
261 readonly cwd: string;
262 readonly repository: string;
263 readonly number: number;
264 /** Absent asks for the first slice; anything else is a cursor a slice handed back. */
265 readonly cursor?: string | undefined;
266 /** One commit's own changes, rather than everything the merge request carries. */
267 readonly commit?: string | undefined;
268 }) => Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError>;
269
270 readonly getMergeRequestDiffFileContents: (input: {
271 readonly cwd: string;
272 readonly repository: string;
273 readonly number: number;
274 readonly commit?: string | undefined;
275 readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted";
276 readonly oldPath: string;
277 readonly newPath: string;
278 }) => Effect.Effect<
279 { readonly oldContents: string; readonly newContents: string },
280 GitLabPullRequestCliError
281 >;
282
283 readonly getProjectMergeCapabilities: (input: {
284 readonly cwd: string;
285 readonly repository: string;
286 }) => Effect.Effect<PullRequestMergeCapabilities, GitLabPullRequestCliError>;
287
288 /**
289 * What the merge request's head has of each of these paths, as blob ids.
290 *
291 * The head sha comes from the merge request's own diff refs, so the answer is the version a
292 * reader is looking at rather than whatever the source branch has moved on to. A path the
293 * head does not have is answered as the empty revision, since the batch it was asked in was
294 * looked at; a batch GitLab did not answer for is left out instead.
295 */
296 readonly getFileRevisions: (input: {
297 readonly cwd: string;
298 readonly repository: string;
299 readonly number: number;
300 readonly paths: ReadonlyArray<string>;
301 }) => Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError>;
302
303 /**
304 * Who this merge request may be sent to, and who it has already been sent to. Two reads at
305 * once, because GitLab keeps the people with access on the project and the reviewers on the
306 * merge request, and neither answers for the other.
307 */
308 readonly listReviewerCandidates: (input: {
309 readonly cwd: string;
310 readonly repository: string;
311 readonly number: number;
312 }) => Effect.Effect<PullRequestReviewerCandidateList, GitLabPullRequestCliError>;
313
314 readonly setReviewerRequest: (input: {
315 readonly cwd: string;
316 readonly repository: string;
317 readonly number: number;
318 readonly reviewers: ReadonlyArray<{ readonly id: string }>;
319 readonly requested: boolean;
320 }) => Effect.Effect<void, GitLabPullRequestCliError>;
321
322 readonly runMergeRequestAction: (input: {
323 readonly cwd: string;
324 readonly repository: string;
325 readonly number: number;
326 readonly action: PullRequestAction;
327 readonly mergeMethod?: PullRequestMergeMethod;
328 }) => Effect.Effect<void, GitLabPullRequestCliError>;
329
330 /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */
331 readonly updateMergeRequest: (input: {
332 readonly cwd: string;
333 readonly repository: string;
334 readonly number: number;
335 readonly title?: string | undefined;
336 readonly description?: string | undefined;
337 }) => Effect.Effect<void, GitLabPullRequestCliError>;
338
339 readonly commentOnMergeRequest: (input: {
340 readonly cwd: string;
341 readonly repository: string;
342 readonly number: number;
343 readonly body: string;
344 }) => Effect.Effect<void, GitLabPullRequestCliError>;
345
346 readonly updateNote: (input: {
347 readonly cwd: string;
348 readonly repository: string;
349 readonly number: number;
350 readonly noteId: string;
351 readonly body: string;
352 }) => Effect.Effect<void, GitLabPullRequestCliError>;
353
354 readonly listDiscussions: (input: {
355 readonly cwd: string;
356 readonly repository: string;
357 readonly number: number;
358 }) => Effect.Effect<
359 { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean },
360 GitLabPullRequestCliError
361 >;
362
363 readonly submitReview: (input: {
364 readonly cwd: string;
365 readonly repository: string;
366 readonly number: number;
367 readonly verdict: PullRequestReviewVerdict;
368 readonly body: string;
369 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
370 }) => Effect.Effect<void, GitLabPullRequestCliError>;
371
372 readonly replyToDiscussion: (input: {
373 readonly cwd: string;
374 readonly repository: string;
375 readonly number: number;
376 readonly discussionId: string;
377 readonly body: string;
378 }) => Effect.Effect<void, GitLabPullRequestCliError>;
379
380 readonly setDiscussionResolution: (input: {
381 readonly cwd: string;
382 readonly repository: string;
383 readonly number: number;
384 readonly discussionId: string;
385 readonly resolved: boolean;
386 }) => Effect.Effect<void, GitLabPullRequestCliError>;
387
388 /** The awards on the merge request and on every note of it, keyed by the note's REST id. */
389 readonly listReactions: (input: {
390 readonly cwd: string;
391 readonly repository: string;
392 readonly number: number;
393 }) => Effect.Effect<
394 {
395 readonly reactions: ReadonlyArray<PullRequestReaction>;
396 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
397 },
398 GitLabPullRequestCliError
399 >;
400
401 /**
402 * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent
403 * awards the merge request itself, which is where its description's reactions live.
404 */
405 readonly setReaction: (input: {
406 readonly cwd: string;
407 readonly repository: string;
408 readonly number: number;
409 readonly noteId?: string | undefined;
410 readonly content: PullRequestReactionContent;
411 readonly reacted: boolean;
412 }) => Effect.Effect<void, GitLabPullRequestCliError>;
413 }
414>()("@t3tools/source-control-gitlab/server/GitLabPullRequestCli") {}
415
416/** The REST API addresses a project by its URL-encoded full path. */
417function projectPath(repository: string): string {
418 return encodeURIComponent(repository.trim());
419}
420
421function gitLabReviewPositionLines(
422 position: PullRequestReviewPosition,
423):
424 | { readonly new_line: number }
425 | { readonly old_line: number }
426 | { readonly old_line: number; readonly new_line: number } {
427 switch (position.kind) {
428 case "added":
429 return { new_line: position.newLine };
430 case "deleted":
431 return { old_line: position.oldLine };
432 case "context":
433 return { old_line: position.oldLine, new_line: position.newLine };
434 }
435}
436
437function stateParam(state: PullRequestListState): string {
438 // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed,
439 // and it spans every state under `all`.
440 return state === "open" ? "opened" : state;
441}
442
443function involvementParams(input: {
444 readonly involvement: PullRequestInvolvement;
445 readonly viewer: string;
446}): ReadonlyArray<readonly [string, string]> {
447 switch (input.involvement) {
448 case "authored":
449 return [["author_username", input.viewer]];
450 case "reviewing":
451 return [["reviewer_username", input.viewer]];
452 case "all":
453 return [];
454 }
455}
456
457/**
458 * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor
459 * arrives from the reader as a string and goes straight into a query, so it is parsed rather
460 * than trusted; the length bound keeps a page number out of exponential notation.
461 */
462function diffCursorPage(cursor: string): number | null {
463 return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null;
464}
465
466/**
467 * A commit sha arrives from the reader and goes straight into a request path, so it is checked
468 * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a
469 * whole sha.
470 */
471function isCommitSha(value: string): boolean {
472 return /^[0-9a-f]{7,64}$/i.test(value);
473}
474
475function searchParams(search: string | undefined): ReadonlyArray<readonly [string, string]> {
476 const trimmed = search?.trim() ?? "";
477 return trimmed.length === 0 ? [] : [["search", trimmed]];
478}
479
480function query(params: ReadonlyArray<readonly [string, string]>): string {
481 return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
482}
483
484function actionArgs(
485 action: PullRequestAction,
486 mergeMethod: PullRequestMergeMethod | undefined,
487): ReadonlyArray<string> {
488 switch (action) {
489 case "merge":
490 return [
491 "merge",
492 // glab turns on auto-merge whenever a pipeline is running. The button means merge now.
493 "--auto-merge=false",
494 "--yes",
495 ...(mergeMethod === "squash" ? ["--squash"] : []),
496 ...(mergeMethod === "rebase" ? ["--rebase"] : []),
497 ];
498 // The same command with the flag the other way up: here the wait is the whole point, so
499 // glab is told to arm the merge rather than talked out of it.
500 case "enable-auto-merge":
501 return [
502 "merge",
503 "--auto-merge=true",
504 "--yes",
505 ...(mergeMethod === "squash" ? ["--squash"] : []),
506 ...(mergeMethod === "rebase" ? ["--rebase"] : []),
507 ];
508 // Never reached: taking the arming back has no `glab mr` command, so it goes to the API.
509 case "disable-auto-merge":
510 return [];
511 case "ready":
512 return ["update", "--ready"];
513 case "draft":
514 return ["update", "--draft"];
515 case "close":
516 return ["close"];
517 // A rebase, because GitLab has no other way to move a branch onto its target: there is no
518 // merge-the-target-in equivalent of GitHub's update button, which is why this host declares
519 // `rebase` alone and never has to read the method it was handed.
520 case "update-branch":
521 return ["rebase"];
522 case "reopen":
523 return ["reopen"];
524 // Never reached: this host does not declare the action, so the service refuses it first.
525 case "revert":
526 case "approve-workflows":
527 case "delete-branch":
528 throw new Error(`GitLab merge request action ${action} is unsupported`);
529 }
530}
531
532/** @public Service construction is part of the canonical Effect module API. */
533export const make = Effect.gen(function* () {
534 const gitlab = yield* GitLabCli.GitLabCli;
535
536 const api = (input: {
537 readonly cwd: string;
538 readonly path: string;
539 readonly method?: string;
540 readonly stdin?: string;
541 readonly maxOutputBytes?: number;
542 readonly timeoutMs?: number;
543 }) =>
544 gitlab.execute({
545 cwd: input.cwd,
546 args: [
547 "api",
548 input.path,
549 ...(input.method === undefined ? [] : ["--method", input.method]),
550 // A raw body from stdin: argv is visible in process listings and is echoed back
551 // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no
552 // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415.
553 ...(input.stdin === undefined
554 ? []
555 : ["--input", "-", "--header", "Content-Type: application/json"]),
556 ],
557 ...(input.stdin === undefined ? {} : { stdin: input.stdin }),
558 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
559 ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
560 });
561
562 /**
563 * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is
564 * bounded twice over: it stops on a short page or once the extra row that reveals a next
565 * page has been read, and it never asks for more pages than the caller's page needs. The
566 * second bound is what makes it terminate when every row on a page fails to decode, which
567 * leaves nothing collected but does not mean GitLab has run out of rows.
568 */
569 const listPage = (input: {
570 readonly cwd: string;
571 readonly repository: string;
572 readonly state: PullRequestListState;
573 readonly involvement: PullRequestInvolvement;
574 readonly viewer: string;
575 readonly limit: number;
576 readonly query?: string | undefined;
577 readonly cursor?: ProviderListCursor | undefined;
578 readonly page: number;
579 readonly collected: ReadonlyArray<GitLabMergeRequestListItem>;
580 readonly cursorAdvance: number;
581 }): Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError> => {
582 // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has
583 // no tie-breaker, so a page where many rows share the boundary would otherwise return the
584 // same prefix forever. `delivered` is the stable offset the service has already handed over.
585 const delivered = input.cursor?.delivered ?? 0;
586 const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE);
587 const firstPage = Math.floor(delivered / perPage) + 1;
588 const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0;
589 // A page made entirely of malformed rows has no item from which the service can build a
590 // continuation. Bound the walk to the raw span this request asked for rather than recursing
591 // forever on a host that keeps returning full unusable pages.
592 const lastPage = Math.floor((delivered + input.limit) / perPage) + 1;
593 return api({
594 cwd: input.cwd,
595 path: `projects/${projectPath(input.repository)}/merge_requests?${query([
596 ["state", stateParam(input.state)],
597 ...involvementParams(input),
598 // The listing is read through `glab api` rather than `glab mr list`, so the search is
599 // the REST API's own `search` parameter — the one `mr list --search` passes on. It
600 // matches title and description, and travels URL-encoded like every other value here,
601 // so no text in it can become a parameter of its own.
602 ...searchParams(input.query),
603 ["order_by", "updated_at"],
604 ["sort", "desc"],
605 ["per_page", String(perPage)],
606 ["page", String(input.page)],
607 ])}`,
608 }).pipe(
609 Effect.flatMap((result) => {
610 const raw = result.stdout.trim();
611 if (raw.length === 0) {
612 return Effect.succeed({
613 items: input.collected,
614 truncated: false,
615 cursorAdvance: input.cursorAdvance,
616 });
617 }
618 const decoded = decodeMergeRequestListJson(raw);
619 if (!Result.isSuccess(decoded)) {
620 return Effect.fail(
621 new GitLabMergeRequestReadError({
622 command: "glab",
623 cwd: input.cwd,
624 operation: "listMergeRequests",
625 cause: decoded.failure,
626 }),
627 );
628 }
629 const pageItems: GitLabMergeRequestListItem[] = [];
630 const pageRawIndexes: number[] = [];
631 for (const [index, item] of decoded.success.items.entries()) {
632 const rawIndex = decoded.success.rawIndexes[index]!;
633 if (rawIndex < skipOnFirstPage) continue;
634 pageItems.push(item);
635 pageRawIndexes.push(rawIndex);
636 }
637 const remaining = input.limit - input.collected.length;
638 const lastItemRawIndex = pageRawIndexes[remaining - 1];
639 if (lastItemRawIndex !== undefined) {
640 const consumed = lastItemRawIndex + 1 - skipOnFirstPage;
641 return Effect.succeed({
642 items: [...input.collected, ...pageItems.slice(0, remaining)],
643 truncated:
644 lastItemRawIndex + 1 < decoded.success.rawCount ||
645 decoded.success.rawCount === perPage,
646 cursorAdvance: input.cursorAdvance + consumed,
647 });
648 }
649 const collected = [...input.collected, ...pageItems];
650 const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage);
651 // Counted before decoding, so a skipped malformed row cannot end paging early.
652 const exhausted = decoded.success.rawCount < perPage;
653 if (exhausted) {
654 return Effect.succeed({
655 items: collected,
656 truncated: false,
657 cursorAdvance: input.cursorAdvance + consumed,
658 });
659 }
660 if (input.page >= lastPage) {
661 return Effect.succeed({
662 items: collected,
663 truncated: true,
664 cursorAdvance: input.cursorAdvance + consumed,
665 });
666 }
667 return listPage({
668 ...input,
669 page: input.page + 1,
670 collected,
671 cursorAdvance: input.cursorAdvance + consumed,
672 });
673 }),
674 );
675 };
676
677 /**
678 * One page of a merge request's files, as a patch that stands on its own. GitLab pages
679 * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the
680 * caller carries on from it for as long as GitLab keeps handing full pages back.
681 *
682 * A named commit is read from the commit's own diff, which answers in the same shape and pages
683 * the same way.
684 */
685 const diffPage = (input: {
686 readonly cwd: string;
687 readonly repository: string;
688 readonly number: number;
689 readonly page: number;
690 readonly commit?: string | undefined;
691 }): Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError> =>
692 api({
693 cwd: input.cwd,
694 path: `projects/${projectPath(input.repository)}/${
695 input.commit === undefined
696 ? `merge_requests/${input.number}/diffs`
697 : `repository/commits/${input.commit}/diff`
698 }?${query([
699 ["per_page", String(MAX_PAGE_SIZE)],
700 ["page", String(input.page)],
701 ])}`,
702 maxOutputBytes: DIFF_MAX_OUTPUT_BYTES,
703 timeoutMs: DIFF_TIMEOUT_MS,
704 }).pipe(
705 Effect.flatMap((result) => {
706 // A byte-truncated response is a JSON prefix, so this page cannot be read at all.
707 // Answering with no cursor would call the diff whole while silently dropping this page
708 // and every one after it, so the read fails and says which page could not be had.
709 if (result.stdoutTruncated) {
710 return Effect.fail(
711 new GitLabMergeRequestReadError({
712 command: "glab",
713 cwd: input.cwd,
714 operation: "getMergeRequestDiff",
715 cause: new Error(
716 `Page ${input.page} of the merge request diff was too large to read.`,
717 ),
718 }),
719 );
720 }
721 const decoded = decodeMergeRequestDiffsJson(result.stdout.trim());
722 if (!Result.isSuccess(decoded)) {
723 return Effect.fail(
724 new GitLabMergeRequestReadError({
725 command: "glab",
726 cwd: input.cwd,
727 operation: "getMergeRequestDiff",
728 cause: decoded.failure,
729 }),
730 );
731 }
732 const patch = decoded.success.patch;
733 // Counted before decoding, so a page whose files all failed to decode still moves on
734 // rather than pointing the reader back at the page it just read.
735 const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE;
736 return Effect.succeed({
737 // The slice ends on a newline, so a file GitLab gave a header and no hunks for does
738 // not run into the first line of the next slice.
739 patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"),
740 truncated: decoded.success.truncated,
741 nextCursor: morePages ? String(input.page + 1) : null,
742 });
743 }),
744 );
745
746 /**
747 * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short
748 * page is the only thing that says it is done — and the raw count decides, not the kept one:
749 * the notes GitLab wrote itself are dropped, and a whole page of them still means there is
750 * more to read.
751 */
752 const notesPage = (input: {
753 readonly cwd: string;
754 readonly repository: string;
755 readonly number: number;
756 readonly page: number;
757 readonly collected: ReadonlyArray<PullRequestComment>;
758 }): Effect.Effect<
759 { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean },
760 GitLabPullRequestCliError
761 > =>
762 api({
763 cwd: input.cwd,
764 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query(
765 [
766 ["per_page", String(MAX_PAGE_SIZE)],
767 ["page", String(input.page)],
768 ["order_by", "created_at"],
769 ["sort", "asc"],
770 ],
771 )}`,
772 }).pipe(
773 Effect.flatMap((result) => {
774 const decoded = decodeNotesJson(result.stdout.trim());
775 if (!Result.isSuccess(decoded)) {
776 return Effect.fail(
777 new GitLabMergeRequestReadError({
778 command: "glab",
779 cwd: input.cwd,
780 operation: "listNotes",
781 cause: decoded.failure,
782 }),
783 );
784 }
785 const collected = [...input.collected, ...decoded.success.comments];
786 if (decoded.success.rawCount < MAX_PAGE_SIZE) {
787 return Effect.succeed({ comments: collected, truncated: false });
788 }
789 return input.page >= CONVERSATION_PAGES
790 ? Effect.succeed({ comments: collected, truncated: true })
791 : notesPage({ ...input, page: input.page + 1, collected });
792 }),
793 );
794
795 /** The positioned discussions, walked the same way and stopped by the same bound. */
796 const discussionsPage = (input: {
797 readonly cwd: string;
798 readonly repository: string;
799 readonly number: number;
800 readonly page: number;
801 readonly collected: ReadonlyArray<PullRequestReviewThread>;
802 }): Effect.Effect<
803 { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean },
804 GitLabPullRequestCliError
805 > =>
806 api({
807 cwd: input.cwd,
808 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions?${query(
809 [
810 ["per_page", String(MAX_PAGE_SIZE)],
811 ["page", String(input.page)],
812 ],
813 )}`,
814 }).pipe(
815 Effect.flatMap((result) => {
816 const decoded = decodeDiscussionsJson(result.stdout.trim());
817 if (!Result.isSuccess(decoded)) {
818 return Effect.fail(
819 new GitLabMergeRequestReadError({
820 command: "glab",
821 cwd: input.cwd,
822 operation: "listDiscussions",
823 cause: decoded.failure,
824 }),
825 );
826 }
827 const collected = [...input.collected, ...decoded.success.threads];
828 // The raw count again: this endpoint returns the plain notes too, so a full page of
829 // those is not the end of the positioned ones.
830 if (decoded.success.rawCount < MAX_PAGE_SIZE) {
831 return Effect.succeed({ threads: collected, truncated: false });
832 }
833 return input.page >= CONVERSATION_PAGES
834 ? Effect.succeed({ threads: collected, truncated: true })
835 : discussionsPage({ ...input, page: input.page + 1, collected });
836 }),
837 );
838
839 /**
840 * The revisions a positioned comment is written against. GitLab resolves a comment's line
841 * against these three shas, so a review with line comments cannot be sent without them.
842 */
843 const getDiffRefs = (input: {
844 readonly cwd: string;
845 readonly repository: string;
846 readonly number: number;
847 }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> =>
848 api({
849 cwd: input.cwd,
850 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
851 }).pipe(
852 Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => {
853 const decoded = decodeDiffRefsJson(result.stdout.trim());
854 if (!Result.isSuccess(decoded)) {
855 return Effect.fail(
856 new GitLabMergeRequestReadError({
857 command: "glab",
858 cwd: input.cwd,
859 operation: "getDiffRefs",
860 cause: decoded.failure,
861 }),
862 );
863 }
864 // A merge request with no diff refs is a well-formed answer that cannot carry a
865 // positioned comment — a dead end, but not something that failed to be read.
866 return decoded.success === null
867 ? Effect.fail(
868 new GitLabDiffRefsUnavailableError({
869 command: "glab",
870 cwd: input.cwd,
871 number: input.number,
872 }),
873 )
874 : Effect.succeed(decoded.success);
875 }),
876 );
877
878 const getCommitDiffRefs = (input: {
879 readonly cwd: string;
880 readonly repository: string;
881 readonly commit: string;
882 readonly allowRoot: boolean;
883 }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> =>
884 api({
885 cwd: input.cwd,
886 path: `projects/${projectPath(input.repository)}/repository/commits/${input.commit}`,
887 }).pipe(
888 Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => {
889 const decoded = decodeCommitDiffRefsJson(result.stdout.trim());
890 if (!Result.isSuccess(decoded)) {
891 return Effect.fail(
892 new GitLabMergeRequestReadError({
893 command: "glab",
894 cwd: input.cwd,
895 operation: "getMergeRequestDiffFileContents",
896 cause: decoded.failure,
897 }),
898 );
899 }
900 return decoded.success === null
901 ? input.allowRoot
902 ? Effect.succeed({
903 baseSha: "",
904 headSha: input.commit,
905 startSha: "",
906 })
907 : Effect.fail(
908 new GitLabDiffCommitParentUnavailableError({
909 command: "glab",
910 cwd: input.cwd,
911 commit: input.commit,
912 }),
913 )
914 : Effect.succeed(decoded.success);
915 }),
916 );
917
918 /**
919 * The merge request itself, which several calls need for different parts of it: the detail for
920 * everything, and the reviewer paths for the ids GitLab writes a reviewer set with.
921 */
922 const mergeRequestDetail = (input: {
923 readonly cwd: string;
924 readonly repository: string;
925 readonly number: number;
926 }): Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError> =>
927 api({
928 cwd: input.cwd,
929 // How far behind the target branch this one is comes only when asked for by name, and it
930 // is asked for here rather than on a second read because it is the same merge request.
931 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([
932 ["include_diverged_commits_count", "true"],
933 ])}`,
934 }).pipe(
935 Effect.flatMap((result) => {
936 const decoded = decodeMergeRequestDetailJson(result.stdout.trim());
937 return Result.isSuccess(decoded)
938 ? Effect.succeed(decoded.success)
939 : Effect.fail(
940 new GitLabMergeRequestReadError({
941 command: "glab",
942 cwd: input.cwd,
943 operation: "getMergeRequestDetail",
944 cause: decoded.failure,
945 }),
946 );
947 }),
948 );
949
950 /** The people with access to the project, one page deep. */
951 const projectUsers = (input: {
952 readonly cwd: string;
953 readonly repository: string;
954 }): Effect.Effect<GitLabProjectUsers, GitLabPullRequestCliError> =>
955 api({
956 cwd: input.cwd,
957 path: `projects/${projectPath(input.repository)}/users?${query([
958 ["per_page", String(MAX_PAGE_SIZE)],
959 ])}`,
960 }).pipe(
961 Effect.flatMap((result) => {
962 const decoded = decodeProjectUsersJson(result.stdout.trim());
963 return Result.isSuccess(decoded)
964 ? Effect.succeed(decoded.success)
965 : Effect.fail(
966 new GitLabMergeRequestReadError({
967 command: "glab",
968 cwd: input.cwd,
969 operation: "listReviewerCandidates",
970 cause: decoded.failure,
971 }),
972 );
973 }),
974 );
975
976 /** Where an award is written: a note of the merge request, or the merge request itself. */
977 const awardSubjectPath = (input: {
978 readonly repository: string;
979 readonly number: number;
980 readonly noteId?: string | undefined;
981 }) => {
982 const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`;
983 return input.noteId === undefined
984 ? `${mergeRequest}/award_emoji`
985 : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`;
986 };
987
988 /**
989 * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same
990 * count as the conversation itself: awards past the notes that were read belong to notes the
991 * page is not showing.
992 */
993 const awardsPage = (input: {
994 readonly cwd: string;
995 readonly repository: string;
996 readonly number: number;
997 readonly cursor: string | null;
998 readonly page: number;
999 readonly collected: {
1000 readonly reactions: ReadonlyArray<PullRequestReaction>;
1001 readonly reactionsByNoteId: Map<string, ReadonlyArray<PullRequestReaction>>;
1002 } | null;
1003 }): Effect.Effect<
1004 {
1005 readonly reactions: ReadonlyArray<PullRequestReaction>;
1006 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
1007 },
1008 GitLabPullRequestCliError
1009 > =>
1010 api({
1011 cwd: input.cwd,
1012 path: "graphql",
1013 method: "POST",
1014 stdin: JSON.stringify({
1015 query: AWARD_EMOJI_GRAPHQL_QUERY,
1016 variables: {
1017 fullPath: input.repository,
1018 iid: String(input.number),
1019 cursor: input.cursor,
1020 },
1021 }),
1022 }).pipe(
1023 Effect.flatMap((result) => {
1024 const decoded = decodeAwardEmojiJson(result.stdout.trim());
1025 if (!Result.isSuccess(decoded)) {
1026 return Effect.fail(
1027 new GitLabMergeRequestReadError({
1028 command: "glab",
1029 cwd: input.cwd,
1030 operation: "listReactions",
1031 cause: decoded.failure,
1032 }),
1033 );
1034 }
1035 const collected = input.collected ?? {
1036 reactions: decoded.success.reactions,
1037 reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(),
1038 };
1039 for (const [id, reactions] of decoded.success.reactionsByNoteId)
1040 collected.reactionsByNoteId.set(id, reactions);
1041 return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES
1042 ? Effect.succeed(collected)
1043 : awardsPage({
1044 ...input,
1045 cursor: decoded.success.nextCursor,
1046 page: input.page + 1,
1047 collected,
1048 });
1049 }),
1050 );
1051
1052 /**
1053 * GitLab charges the blobs query by how many paths it is handed, and its connection hands back
1054 * one page. A hundred at a time keeps each request inside both.
1055 */
1056 const BLOB_PATHS_PER_REQUEST = 100;
1057
1058 const blobsAt = (input: {
1059 readonly cwd: string;
1060 readonly repository: string;
1061 readonly ref: string;
1062 readonly paths: ReadonlyArray<string>;
1063 }): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> =>
1064 api({
1065 cwd: input.cwd,
1066 path: "graphql",
1067 method: "POST",
1068 stdin: JSON.stringify({
1069 query: REPOSITORY_BLOBS_GRAPHQL_QUERY,
1070 variables: { fullPath: input.repository, ref: input.ref, paths: input.paths },
1071 }),
1072 }).pipe(
1073 Effect.flatMap(
1074 (result): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> => {
1075 const decoded = decodeRepositoryBlobsJson(result.stdout.trim());
1076 return Result.isSuccess(decoded)
1077 ? Effect.succeed(decoded.success)
1078 : Effect.fail(
1079 new GitLabMergeRequestReadError({
1080 command: "glab",
1081 cwd: input.cwd,
1082 operation: "getFileRevisions",
1083 cause: decoded.failure,
1084 }),
1085 );
1086 },
1087 ),
1088 );
1089
1090 const fileRevisions = (input: {
1091 readonly cwd: string;
1092 readonly repository: string;
1093 readonly number: number;
1094 readonly paths: ReadonlyArray<string>;
1095 }): Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError> =>
1096 input.paths.length === 0
1097 ? Effect.succeed(new Map())
1098 : getDiffRefs(input).pipe(
1099 Effect.flatMap((refs) => {
1100 const batches: Array<ReadonlyArray<string>> = [];
1101 for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) {
1102 batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST));
1103 }
1104 return Effect.forEach(
1105 batches,
1106 (paths) =>
1107 blobsAt({ ...input, ref: refs.headSha, paths }).pipe(
1108 Effect.map((page) => ({ paths, page })),
1109 ),
1110 { concurrency: 2 },
1111 ).pipe(
1112 Effect.map((pages) => {
1113 const revisions = new Map<string, string>();
1114 for (const { paths, page } of pages) {
1115 // A batch GitLab did not answer says nothing about its paths, so they are left
1116 // out and the caller reads them as versions it could not learn, which leaves
1117 // the marks on them alone. Filling them in as removed would report every file
1118 // a reader has cleared as changed over a project the token cannot see.
1119 if (page === null) continue;
1120 for (const [path, oid] of page) revisions.set(path, oid);
1121 // Within a batch that was answered, every path was looked for at the head, so
1122 // one that is not there is one the merge request removed. Said as the empty
1123 // revision, which is an answer the caller can compare against and keep.
1124 for (const path of paths) {
1125 if (!revisions.has(path)) revisions.set(path, "");
1126 }
1127 }
1128 return revisions as ReadonlyMap<string, string>;
1129 }),
1130 );
1131 }),
1132 );
1133
1134 const viewerUsername = (input: { readonly cwd: string }) =>
1135 api({ cwd: input.cwd, path: "user" }).pipe(
1136 Effect.flatMap((result): Effect.Effect<string, GitLabPullRequestCliError> => {
1137 const decoded = decodeViewerJson(result.stdout.trim());
1138 if (!Result.isSuccess(decoded)) {
1139 return Effect.fail(
1140 new GitLabMergeRequestReadError({
1141 command: "glab",
1142 cwd: input.cwd,
1143 operation: "getViewerUsername",
1144 cause: decoded.failure,
1145 }),
1146 );
1147 }
1148 return decoded.success === null
1149 ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd }))
1150 : Effect.succeed(decoded.success);
1151 }),
1152 );
1153
1154 return GitLabPullRequestCli.of({
1155 getViewerUsername: viewerUsername,
1156
1157 listMergeRequests: (input) => {
1158 const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE);
1159 const page = Math.floor((input.cursor?.delivered ?? 0) / perPage) + 1;
1160 return listPage({ ...input, page, collected: [], cursorAdvance: 0 });
1161 },
1162
1163 getMergeRequestDetail: mergeRequestDetail,
1164
1165 listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }),
1166
1167 listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }),
1168
1169 getFileRevisions: fileRevisions,
1170
1171 setReaction: (input) =>
1172 Effect.gen(function* () {
1173 const subject = awardSubjectPath(input);
1174 if (input.reacted) {
1175 yield* api({
1176 cwd: input.cwd,
1177 path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`,
1178 method: "POST",
1179 });
1180 return;
1181 }
1182 // GitLab deletes an award by its id and takes no emoji name there, so the reader's own
1183 // award of that name is looked up first. Nothing to delete is success: the reaction the
1184 // caller asked to take back is already gone.
1185 const viewer = yield* viewerUsername({ cwd: input.cwd });
1186 const listed = yield* api({ cwd: input.cwd, path: subject });
1187 const own = decodeOwnAwardIdJson(listed.stdout.trim(), {
1188 content: input.content,
1189 viewer,
1190 });
1191 if (!Result.isSuccess(own)) {
1192 return yield* new GitLabMergeRequestReadError({
1193 command: "glab",
1194 cwd: input.cwd,
1195 operation: "setReaction",
1196 cause: own.failure,
1197 });
1198 }
1199 if (own.success === null) return;
1200 yield* api({
1201 cwd: input.cwd,
1202 path: `${subject}/${own.success}`,
1203 method: "DELETE",
1204 });
1205 }),
1206
1207 listCommits: (input) =>
1208 api({
1209 cwd: input.cwd,
1210 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query(
1211 [
1212 ["per_page", String(COMMIT_PAGE_SIZE)],
1213 ["with_stats", "true"],
1214 ],
1215 )}`,
1216 }).pipe(
1217 Effect.flatMap((result) => {
1218 const decoded = decodeCommitsJson(result.stdout.trim());
1219 return Result.isSuccess(decoded)
1220 ? Effect.succeed(decoded.success)
1221 : Effect.fail(
1222 new GitLabMergeRequestReadError({
1223 command: "glab",
1224 cwd: input.cwd,
1225 operation: "listCommits",
1226 cause: decoded.failure,
1227 }),
1228 );
1229 }),
1230 ),
1231
1232 getMergeRequestDiff: (input) => {
1233 if (input.commit !== undefined && !isCommitSha(input.commit)) {
1234 return Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }));
1235 }
1236 const target = {
1237 cwd: input.cwd,
1238 repository: input.repository,
1239 number: input.number,
1240 ...(input.commit === undefined ? {} : { commit: input.commit }),
1241 };
1242 if (input.cursor === undefined) {
1243 return diffPage({ ...target, page: 1 });
1244 }
1245 const page = diffCursorPage(input.cursor);
1246 return page === null
1247 ? Effect.fail(new GitLabDiffCursorError({ command: "glab", cwd: input.cwd }))
1248 : diffPage({ ...target, page });
1249 },
1250
1251 getMergeRequestDiffFileContents: (input) =>
1252 Effect.gen(function* () {
1253 if (input.commit !== undefined && !isCommitSha(input.commit)) {
1254 return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd });
1255 }
1256 const refs = yield* input.commit === undefined
1257 ? getDiffRefs(input)
1258 : getCommitDiffRefs({
1259 cwd: input.cwd,
1260 repository: input.repository,
1261 commit: input.commit,
1262 allowRoot: input.changeType === "new",
1263 });
1264
1265 const readFile = (revision: string, filePath: string) =>
1266 api({
1267 cwd: input.cwd,
1268 path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent(
1269 filePath,
1270 )}/raw?ref=${encodeURIComponent(revision)}`,
1271 maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES,
1272 timeoutMs: DIFF_TIMEOUT_MS,
1273 }).pipe(
1274 Effect.flatMap((result) =>
1275 result.stdoutTruncated ||
1276 result.stdout.includes("\0") ||
1277 result.stdoutInvalidUtf8 === true
1278 ? Effect.fail(
1279 new GitLabDiffFileContentsUnavailableError({
1280 command: "glab",
1281 cwd: input.cwd,
1282 path: filePath,
1283 reason: result.stdoutTruncated ? "oversized" : "binary",
1284 }),
1285 )
1286 : Effect.succeed(result.stdout),
1287 ),
1288 );
1289
1290 const [oldContents, newContents] = yield* Effect.all(
1291 [
1292 input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath),
1293 input.changeType === "deleted"
1294 ? Effect.succeed("")
1295 : readFile(refs.headSha, input.newPath),
1296 ],
1297 { concurrency: 2 },
1298 );
1299 return { oldContents, newContents };
1300 }),
1301
1302 getProjectMergeCapabilities: (input) =>
1303 api({
1304 cwd: input.cwd,
1305 path: `projects/${projectPath(input.repository)}?license=false`,
1306 }).pipe(
1307 Effect.flatMap((result) => {
1308 const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim());
1309 return Result.isSuccess(decoded)
1310 ? Effect.succeed(decoded.success)
1311 : Effect.fail(
1312 new GitLabMergeRequestReadError({
1313 command: "glab",
1314 cwd: input.cwd,
1315 operation: "getProjectMergeCapabilities",
1316 cause: decoded.failure,
1317 }),
1318 );
1319 }),
1320 ),
1321
1322 listReviewerCandidates: (input) =>
1323 Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe(
1324 Effect.map(([mergeRequest, users]) => {
1325 const author = mergeRequest.author?.login;
1326 const requested = new Set(mergeRequest.reviewRequestLogins);
1327 return {
1328 // The author is dropped rather than shown unusable: GitLab refuses to make the person
1329 // who opened a merge request its reviewer.
1330 candidates: users.candidates.flatMap((candidate) =>
1331 candidate.login === author
1332 ? []
1333 : [{ ...candidate, isRequested: requested.has(candidate.login) }],
1334 ),
1335 truncated: users.rawCount >= MAX_PAGE_SIZE,
1336 };
1337 }),
1338 ),
1339
1340 setReviewerRequest: (input) =>
1341 mergeRequestDetail(input).pipe(
1342 Effect.flatMap((mergeRequest) => {
1343 // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the
1344 // whole set, so the set that is already there is read first and the change applied to
1345 // it. Asking again for somebody already on it writes the same set back, which is how
1346 // GitLab re-requests a review.
1347 const ids = new Set(mergeRequest.reviewerIds);
1348 for (const reviewer of input.reviewers) {
1349 const id = Number(reviewer.id);
1350 // A candidate GitLab did not name is not an id it would accept, and sending it would
1351 // rewrite the reviewer set around a number nobody chose.
1352 if (!Number.isSafeInteger(id) || id <= 0) continue;
1353 if (input.requested) ids.add(id);
1354 else ids.delete(id);
1355 }
1356 return api({
1357 cwd: input.cwd,
1358 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
1359 method: "PUT",
1360 stdin: JSON.stringify({ reviewer_ids: [...ids] }),
1361 });
1362 }),
1363 Effect.asVoid,
1364 ),
1365
1366 runMergeRequestAction: (input) => {
1367 // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has
1368 // no flag for is asked of GitLab directly through the same `api` passthrough the rest of
1369 // this module writes with.
1370 if (input.action === "disable-auto-merge") {
1371 return api({
1372 cwd: input.cwd,
1373 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`,
1374 method: "POST",
1375 }).pipe(Effect.asVoid);
1376 }
1377 const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod);
1378 return gitlab
1379 .execute({
1380 cwd: input.cwd,
1381 args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags],
1382 })
1383 .pipe(Effect.asVoid);
1384 },
1385
1386 updateMergeRequest: (input) =>
1387 api({
1388 cwd: input.cwd,
1389 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`,
1390 method: "PUT",
1391 // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and
1392 // clears what it is sent empty — so a title corrected on its own must carry no
1393 // description at all.
1394 stdin: JSON.stringify({
1395 ...(input.title === undefined ? {} : { title: input.title }),
1396 ...(input.description === undefined ? {} : { description: input.description }),
1397 }),
1398 }).pipe(Effect.asVoid),
1399
1400 commentOnMergeRequest: (input) =>
1401 api({
1402 cwd: input.cwd,
1403 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`,
1404 method: "POST",
1405 // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a
1406 // literal `true` or a number, and a comment body is text either way.
1407 stdin: JSON.stringify({ body: input.body }),
1408 }).pipe(Effect.asVoid),
1409
1410 updateNote: (input) =>
1411 api({
1412 cwd: input.cwd,
1413 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent(
1414 input.noteId,
1415 )}`,
1416 method: "PUT",
1417 stdin: JSON.stringify({ body: input.body }),
1418 }).pipe(Effect.asVoid),
1419
1420 listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }),
1421
1422 submitReview: (input) =>
1423 Effect.gen(function* () {
1424 const project = projectPath(input.repository);
1425 const mergeRequest = `projects/${project}/merge_requests/${input.number}`;
1426 // GitLab has no pending review to attach comments to, so a review is replayed as the
1427 // requests it is made of: the line comments, then the summary, then the verdict. A
1428 // failure part-way therefore leaves what was already posted in place, which is why
1429 // the verdict goes last — a half-sent review is never an approval.
1430 if (input.comments.length > 0) {
1431 const refs = yield* getDiffRefs(input);
1432 yield* Effect.forEach(
1433 input.comments,
1434 (comment) =>
1435 api({
1436 cwd: input.cwd,
1437 path: `${mergeRequest}/discussions`,
1438 method: "POST",
1439 stdin: JSON.stringify({
1440 body: comment.body,
1441 position: {
1442 base_sha: refs.baseSha,
1443 head_sha: refs.headSha,
1444 start_sha: refs.startSha,
1445 position_type: "text",
1446 // Both paths are sent because GitLab resolves a position against both
1447 // sides of the diff. They differ only for a renamed file, which is why the
1448 // draft carries the name the file had before the change.
1449 old_path: comment.oldPath ?? comment.path,
1450 new_path: comment.path,
1451 ...gitLabReviewPositionLines(comment.position),
1452 },
1453 }),
1454 }),
1455 { discard: true },
1456 );
1457 }
1458 if (input.body.trim().length > 0) {
1459 yield* api({
1460 cwd: input.cwd,
1461 path: `${mergeRequest}/notes`,
1462 method: "POST",
1463 // A JSON body rather than a `--raw-field`, for the reason the plain comment gives:
1464 // glab coerces a field that reads as a literal `true` or a number.
1465 // @effect-diagnostics-next-line preferSchemaOverJson:off
1466 stdin: JSON.stringify({ body: input.body }),
1467 });
1468 }
1469 if (input.verdict === "approve") {
1470 yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" });
1471 }
1472 }),
1473
1474 replyToDiscussion: (input) =>
1475 api({
1476 cwd: input.cwd,
1477 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent(
1478 input.discussionId,
1479 )}/notes`,
1480 method: "POST",
1481 stdin: JSON.stringify({ body: input.body }),
1482 }).pipe(Effect.asVoid),
1483
1484 setDiscussionResolution: (input) =>
1485 api({
1486 cwd: input.cwd,
1487 path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent(
1488 input.discussionId,
1489 )}`,
1490 method: "PUT",
1491 stdin: JSON.stringify({ resolved: input.resolved }),
1492 }).pipe(Effect.asVoid),
1493 });
1494});
1495
1496export const layer = Layer.effect(GitLabPullRequestCli, make);