1import * as Cause from "effect/Cause";
2import * as Exit from "effect/Exit";
3import * as Result from "effect/Result";
4import * as Schema from "effect/Schema";
5import type {
6 PullRequestActor,
7 PullRequestCheck,
8 PullRequestCheckStatus,
9 PullRequestComment,
10 PullRequestCommit,
11 PullRequestLabel,
12 PullRequestMergeability,
13 PullRequestMergeCapabilities,
14 PullRequestMergeMethod,
15 PullRequestReaction,
16 PullRequestReactionContent,
17 PullRequestReviewThread,
18 PullRequestReviewerCandidate,
19 PullRequestState,
20} from "@t3tools/contracts";
21import { TrimmedNonEmptyString } from "@t3tools/contracts";
22import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
23import { decodeJsonResult } from "@t3tools/shared/schemaJson";
24
25/**
26 * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that
27 * adds a pipeline status or a merge status must not fail the whole payload.
28 */
29const RawUserSchema = Schema.Struct({
30 /**
31 * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the
32 * id is carried alongside the handle rather than looked up again when a review is asked for.
33 */
34 id: Schema.optional(Schema.Int),
35 username: Schema.String,
36 name: Schema.optional(Schema.NullOr(Schema.String)),
37 avatar_url: Schema.optional(Schema.NullOr(Schema.String)),
38});
39
40const RawPipelineSchema = Schema.Struct({
41 status: Schema.optional(Schema.NullOr(Schema.String)),
42 web_url: Schema.optional(Schema.NullOr(Schema.String)),
43 source: Schema.optional(Schema.NullOr(Schema.String)),
44});
45
46const RawMergeRequestSchema = Schema.Struct({
47 iid: Schema.Int,
48 title: Schema.String,
49 web_url: Schema.String,
50 description: Schema.optional(Schema.NullOr(Schema.String)),
51 author: Schema.optional(Schema.NullOr(RawUserSchema)),
52 source_branch: Schema.String,
53 target_branch: Schema.String,
54 /** The source branch's head commit. */
55 sha: Schema.optional(Schema.NullOr(Schema.String)),
56 state: Schema.optional(Schema.NullOr(Schema.String)),
57 draft: Schema.optional(Schema.Boolean),
58 work_in_progress: Schema.optional(Schema.Boolean),
59 merge_status: Schema.optional(Schema.NullOr(Schema.String)),
60 has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)),
61 created_at: Schema.String,
62 updated_at: Schema.String,
63 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
64 closed_at: Schema.optional(Schema.NullOr(Schema.String)),
65 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))),
66 labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
67 // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded.
68 changes_count: Schema.optional(Schema.NullOr(Schema.String)),
69 head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)),
70 /**
71 * What the requesting account may do, which only the single-merge-request endpoint carries.
72 * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts
73 * for the role, the approval rules and a protected target branch — none of which a project's
74 * access level on its own would tell apart.
75 */
76 user: Schema.optional(
77 Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })),
78 ),
79 /**
80 * Whether GitLab is holding this merge request to merge it once its pipeline goes green.
81 * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also
82 * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so
83 * either one saying yes is a yes.
84 */
85 merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)),
86 auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)),
87 /** The merge request's stored squash choice, including project-policy overrides. */
88 squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)),
89 squash: Schema.optional(Schema.NullOr(Schema.Boolean)),
90 /**
91 * How far the target branch has moved on since this one left it, which is the same number
92 * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab
93 * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a
94 * single merge request — a list never carries it, however it is asked for.
95 */
96 diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)),
97});
98
99const RawNoteSchema = Schema.Struct({
100 id: Schema.Int,
101 body: Schema.optional(Schema.NullOr(Schema.String)),
102 author: Schema.optional(Schema.NullOr(RawUserSchema)),
103 created_at: Schema.String,
104 /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */
105 system: Schema.optional(Schema.Boolean),
106 type: Schema.optional(Schema.NullOr(Schema.String)),
107 position: Schema.optional(
108 Schema.NullOr(
109 Schema.Struct({
110 new_path: Schema.optional(Schema.NullOr(Schema.String)),
111 old_path: Schema.optional(Schema.NullOr(Schema.String)),
112 }),
113 ),
114 ),
115});
116
117/**
118 * A discussion note carrying its place in the diff, which is the shape the whole thread view
119 * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a
120 * discussion resolved once every resolvable note in it is.
121 */
122const RawDiscussionNoteSchema = Schema.Struct({
123 id: Schema.Int,
124 body: Schema.optional(Schema.NullOr(Schema.String)),
125 author: Schema.optional(Schema.NullOr(RawUserSchema)),
126 created_at: Schema.String,
127 system: Schema.optional(Schema.Boolean),
128 resolvable: Schema.optional(Schema.Boolean),
129 resolved: Schema.optional(Schema.NullOr(Schema.Boolean)),
130 position: Schema.optional(
131 Schema.NullOr(
132 Schema.Struct({
133 position_type: Schema.optional(Schema.NullOr(Schema.String)),
134 new_path: Schema.optional(Schema.NullOr(Schema.String)),
135 old_path: Schema.optional(Schema.NullOr(Schema.String)),
136 new_line: Schema.optional(Schema.NullOr(Schema.Int)),
137 old_line: Schema.optional(Schema.NullOr(Schema.Int)),
138 }),
139 ),
140 ),
141});
142
143const RawDiscussionSchema = Schema.Struct({
144 id: Schema.String,
145 notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))),
146});
147
148const RawDiffRefsSchema = Schema.Struct({
149 diff_refs: Schema.optional(
150 Schema.NullOr(
151 Schema.Struct({
152 base_sha: Schema.String,
153 head_sha: Schema.String,
154 start_sha: Schema.String,
155 }),
156 ),
157 ),
158});
159
160const RawCommitSchema = Schema.Struct({
161 id: TrimmedNonEmptyString,
162 title: Schema.optional(Schema.NullOr(Schema.String)),
163 committed_date: Schema.optional(Schema.NullOr(Schema.String)),
164 created_at: Schema.optional(Schema.NullOr(Schema.String)),
165 parent_ids: Schema.optional(Schema.Array(Schema.String)),
166 author_name: Schema.optional(Schema.NullOr(Schema.String)),
167 author_email: Schema.optional(Schema.NullOr(Schema.String)),
168 stats: Schema.optional(
169 Schema.NullOr(
170 Schema.Struct({
171 additions: Schema.optional(Schema.Int),
172 deletions: Schema.optional(Schema.Int),
173 }),
174 ),
175 ),
176});
177
178const RawDiffSchema = Schema.Struct({
179 old_path: Schema.String,
180 new_path: Schema.String,
181 a_mode: Schema.optional(Schema.NullOr(Schema.String)),
182 b_mode: Schema.optional(Schema.NullOr(Schema.String)),
183 new_file: Schema.optional(Schema.Boolean),
184 renamed_file: Schema.optional(Schema.Boolean),
185 deleted_file: Schema.optional(Schema.Boolean),
186 diff: Schema.optional(Schema.NullOr(Schema.String)),
187 /** GitLab omits the hunks for a file it considers too large to inline. */
188 too_large: Schema.optional(Schema.NullOr(Schema.Boolean)),
189 /** And for one it collapsed, which withholds them the same way. */
190 collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)),
191});
192
193const RawViewerSchema = Schema.Struct({
194 username: Schema.optional(Schema.NullOr(Schema.String)),
195});
196
197/** A GitLab project settles on one merge strategy plus an optional squash. */
198const RawProjectMergeSettingsSchema = Schema.Struct({
199 merge_method: Schema.optional(Schema.NullOr(Schema.String)),
200 squash_option: Schema.optional(Schema.NullOr(Schema.String)),
201});
202
203export interface GitLabMergeRequestListItem {
204 readonly number: number;
205 readonly title: string;
206 readonly url: string;
207 readonly author: PullRequestActor | null;
208 readonly headBranch: string;
209 readonly baseBranch: string;
210 readonly state: PullRequestState;
211 readonly isDraft: boolean;
212 readonly mergeability: PullRequestMergeability;
213 /**
214 * GitLab reports neither added nor removed lines on a merge request, so both stay zero and
215 * the surface omits the stat. The Code tab counts them from the patch it already fetched.
216 */
217 readonly additions: number;
218 readonly deletions: number;
219 readonly createdAt: string;
220 readonly updatedAt: string;
221 readonly reviewRequestLogins: ReadonlyArray<string>;
222 readonly labels: ReadonlyArray<PullRequestLabel>;
223}
224
225export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem {
226 readonly body: string;
227 readonly changedFiles: number;
228 readonly mergedAt: string | null;
229 readonly closedAt: string | null;
230 readonly reviewers: ReadonlyArray<PullRequestActor>;
231 readonly checks: ReadonlyArray<PullRequestCheck>;
232 /** False only where GitLab said so; an answer without the field leaves merging permitted. */
233 readonly viewerCanMerge: boolean;
234 /** The reviewers as GitLab addresses them, which is what writing the set back takes. */
235 readonly reviewerIds: ReadonlyArray<number>;
236 /** Absent where GitLab named neither auto-merge field, which is not the same as off. */
237 readonly autoMergeEnabled?: boolean;
238 /** GitLab only exposes the stored strategy separately when that strategy is squash. */
239 readonly autoMergeMethod?: PullRequestMergeMethod;
240 /**
241 * Absent where GitLab did not count, which is not the same as a branch that has nothing behind
242 * it: an install too old to answer must not be read as saying the branch is current.
243 */
244 readonly divergedCommits?: number;
245 /** Absent where GitLab did not report the head commit. */
246 readonly headSha?: string;
247}
248
249function trimmed(value: string | null | undefined): string | null {
250 const text = value?.trim() ?? "";
251 return text.length > 0 ? text : null;
252}
253
254function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) {
255 const login = trimmed(raw?.username);
256 return login === null
257 ? null
258 : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) };
259}
260
261function toState(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): PullRequestState {
262 if (trimmed(raw.merged_at) !== null) return "merged";
263 switch (raw.state?.trim().toLowerCase()) {
264 case "merged":
265 return "merged";
266 case "closed":
267 return "closed";
268 default:
269 // `locked` is an open merge request whose discussion is locked.
270 return "open";
271 }
272}
273
274function toMergeability(
275 raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,
276): PullRequestMergeability {
277 if (raw.has_conflicts === true) return "conflicting";
278 switch (raw.merge_status?.trim().toLowerCase()) {
279 case "can_be_merged":
280 return "mergeable";
281 case "cannot_be_merged":
282 return "conflicting";
283 default:
284 // `unchecked` and `checking` mean GitLab has not finished the merge check yet.
285 return "unknown";
286 }
287}
288
289function toLabels(raw: ReadonlyArray<string> | null | undefined): ReadonlyArray<PullRequestLabel> {
290 // GitLab returns label names only, so there is no colour to carry.
291 return (raw ?? []).flatMap((label) => {
292 const name = trimmed(label);
293 return name === null ? [] : [{ name, color: null }];
294 });
295}
296
297/**
298 * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is
299 * the floor either way, which reads better than dropping an uncounted change set to nothing.
300 */
301function toChangedFiles(value: string | null | undefined): number {
302 const parsed = Number.parseInt(value?.trim() ?? "", 10);
303 return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
304}
305
306function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus {
307 switch (value?.trim().toLowerCase()) {
308 case "success":
309 return "success";
310 case "failed":
311 return "failure";
312 case "canceled":
313 case "cancelling":
314 return "cancelled";
315 case "skipped":
316 return "skipped";
317 // A pipeline waiting on a person is not progress, and it is not a failure either.
318 case "manual":
319 case "scheduled":
320 return "neutral";
321 default:
322 return "pending";
323 }
324}
325
326/**
327 * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one
328 * check. The jobs behind it stay one click away through the pipeline URL.
329 */
330function toChecks(
331 raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,
332): ReadonlyArray<PullRequestCheck> {
333 const pipeline = raw.head_pipeline;
334 if (!pipeline) return [];
335 return [
336 {
337 name: "Pipeline",
338 status: toPipelineStatus(pipeline.status),
339 description: trimmed(pipeline.source),
340 url: trimmed(pipeline.web_url),
341 },
342 ];
343}
344
345function toListItem(
346 raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,
347): GitLabMergeRequestListItem {
348 return {
349 number: raw.iid,
350 title: raw.title,
351 url: raw.web_url,
352 author: toActor(raw.author),
353 headBranch: raw.source_branch,
354 baseBranch: raw.target_branch,
355 state: toState(raw),
356 isDraft: raw.draft ?? raw.work_in_progress ?? false,
357 mergeability: toMergeability(raw),
358 additions: 0,
359 deletions: 0,
360 createdAt: raw.created_at,
361 updatedAt: raw.updated_at,
362 reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => {
363 const login = trimmed(reviewer.username);
364 return login === null ? [] : [login];
365 }),
366 labels: toLabels(raw.labels),
367 };
368}
369
370function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail {
371 const listItem = toListItem(raw);
372 const headSha = trimmed(raw.sha);
373 const autoMerge =
374 raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null
375 ? undefined
376 : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true;
377 return {
378 ...listItem,
379 body: raw.description ?? "",
380 changedFiles: toChangedFiles(raw.changes_count),
381 mergedAt: trimmed(raw.merged_at),
382 closedAt: trimmed(raw.closed_at),
383 // Built from the reviewers themselves rather than from their logins, so the avatars survive.
384 reviewers: (raw.reviewers ?? []).flatMap((reviewer) => {
385 const actor = toActor(reviewer);
386 return actor === null ? [] : [actor];
387 }),
388 checks: toChecks(raw),
389 viewerCanMerge: raw.user?.can_merge !== false,
390 reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) =>
391 reviewer.id === undefined ? [] : [reviewer.id],
392 ),
393 ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }),
394 ...(autoMerge === true && raw.squash_on_merge === true
395 ? { autoMergeMethod: "squash" as const }
396 : {}),
397 ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }),
398 ...(headSha === null ? {} : { headSha }),
399 };
400}
401
402const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
403const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema);
404const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema);
405const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema);
406const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema);
407const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema);
408const decodeCommit = decodeJsonResult(RawCommitSchema);
409const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema);
410const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema);
411const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema);
412const decodeViewer = decodeJsonResult(RawViewerSchema);
413const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema);
414
415type DecodeFailure = Cause.Cause<Schema.SchemaError>;
416
417export interface GitLabProjectUsers {
418 readonly candidates: ReadonlyArray<PullRequestReviewerCandidate>;
419 /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */
420 readonly rawCount: number;
421}
422
423export interface GitLabMergeRequestListBatch {
424 readonly items: ReadonlyArray<GitLabMergeRequestListItem>;
425 /** Zero-based positions of the decoded items in GitLab's raw page. */
426 readonly rawIndexes: ReadonlyArray<number>;
427 /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */
428 readonly rawCount: number;
429}
430
431/** Malformed entries are skipped rather than failing the batch: one unexpected merge request
432 * must not blank the whole list. */
433export function decodeMergeRequestListJson(
434 raw: string,
435): Result.Result<GitLabMergeRequestListBatch, DecodeFailure> {
436 const decoded = decodeUnknownList(raw);
437 if (!Result.isSuccess(decoded)) {
438 return Result.fail(decoded.failure);
439 }
440 const items: GitLabMergeRequestListItem[] = [];
441 const rawIndexes: number[] = [];
442 for (const [rawIndex, entry] of decoded.success.entries()) {
443 const item = decodeMergeRequestEntry(entry);
444 if (Exit.isSuccess(item)) {
445 items.push(toListItem(item.value));
446 rawIndexes.push(rawIndex);
447 }
448 }
449 return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length });
450}
451
452export function decodeMergeRequestDetailJson(
453 raw: string,
454): Result.Result<GitLabMergeRequestDetail, DecodeFailure> {
455 const decoded = decodeMergeRequest(raw);
456 return Result.isSuccess(decoded)
457 ? Result.succeed(toDetail(decoded.success))
458 : Result.fail(decoded.failure);
459}
460
461export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {
462 const decoded = decodeViewer(raw);
463 return Result.isSuccess(decoded)
464 ? Result.succeed(trimmed(decoded.success.username))
465 : Result.fail(decoded.failure);
466}
467
468/**
469 * The people with access to the project, which `GET /projects/:id/users` answers with — the same
470 * list GitLab's own reviewer field is filled from, including the members a group above the project
471 * lends it. A malformed row is skipped rather than failing the menu it belongs to.
472 *
473 * Nobody is marked requested here: who has been asked lives on the merge request, and only the
474 * caller holds both.
475 */
476export function decodeProjectUsersJson(
477 raw: string,
478): Result.Result<GitLabProjectUsers, DecodeFailure> {
479 const decoded = decodeUnknownList(raw);
480 if (!Result.isSuccess(decoded)) {
481 return Result.fail(decoded.failure);
482 }
483 const candidates: PullRequestReviewerCandidate[] = [];
484 for (const entry of decoded.success) {
485 const user = decodeUserEntry(entry);
486 if (Exit.isFailure(user) || user.value.id === undefined) continue;
487 const actor = toActor(user.value);
488 if (actor === null) continue;
489 candidates.push({
490 ...actor,
491 id: String(user.value.id),
492 kind: "user",
493 isRequested: false,
494 });
495 }
496 return Result.succeed({ candidates, rawCount: decoded.success.length });
497}
498
499/**
500 * GitLab settles the strategy per project rather than offering all three per merge request:
501 * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a
502 * separate switch. An unrecognized setting offers nothing rather than offering a strategy the
503 * project forbids.
504 */
505export function decodeProjectMergeCapabilitiesJson(
506 raw: string,
507): Result.Result<PullRequestMergeCapabilities, DecodeFailure> {
508 const decoded = decodeProjectMergeSettings(raw);
509 if (!Result.isSuccess(decoded)) {
510 return Result.fail(decoded.failure);
511 }
512 const mergeMethod = decoded.success.merge_method?.trim().toLowerCase();
513 const squashOption = decoded.success.squash_option?.trim().toLowerCase();
514 return Result.succeed({
515 merge: mergeMethod === "merge",
516 // Both semi-linear and fast-forward histories are reached by rebasing onto the target.
517 rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff",
518 // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing,
519 // rather than offering a squash the project may forbid.
520 squash:
521 squashOption === "always" || squashOption === "default_on" || squashOption === "default_off",
522 });
523}
524
525/**
526 * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the
527 * root of a line-level discussion, which is what the review-comment kind means.
528 *
529 * The raw note count comes back alongside, because dropping notes hides whether the page was
530 * full: a caller cannot tell "no more notes" from "a page of activity entries" without it.
531 */
532/** The three revisions a positioned comment is written against. */
533export interface GitLabDiffRefs {
534 readonly baseSha: string;
535 readonly headSha: string;
536 readonly startSha: string;
537}
538
539export interface GitLabDiscussions {
540 readonly threads: ReadonlyArray<PullRequestReviewThread>;
541 /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */
542 readonly rawCount: number;
543}
544
545/**
546 * Positioned discussions only. GitLab returns the merge request's whole conversation here,
547 * including the plain notes the timeline already shows, and only a positioned one belongs
548 * against a line of the diff.
549 */
550export function decodeDiscussionsJson(
551 raw: string,
552): Result.Result<GitLabDiscussions, DecodeFailure> {
553 const decoded = decodeUnknownList(raw);
554 if (!Result.isSuccess(decoded)) {
555 return Result.fail(decoded.failure);
556 }
557 const threads: PullRequestReviewThread[] = [];
558 for (const entry of decoded.success) {
559 const discussion = decodeDiscussionEntry(entry);
560 if (!Exit.isSuccess(discussion)) continue;
561 const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true);
562 const root = notes[0];
563 const position = root?.position;
564 if (root === undefined || !position || position.position_type !== "text") continue;
565 // A comment on an added or context line carries `new_line`; one on a removed line carries
566 // only `old_line`, and belongs against the file as it was.
567 const side = position.new_line === null || position.new_line === undefined ? "left" : "right";
568 const path = trimmed(side === "left" ? position.old_path : position.new_path);
569 const line = side === "left" ? position.old_line : position.new_line;
570 if (path === null) continue;
571 threads.push({
572 id: discussion.value.id,
573 path,
574 line: typeof line === "number" && line > 0 ? line : null,
575 side,
576 isResolved: root.resolved === true,
577 // GitLab reports no equivalent of "written against a line that has since moved", so a
578 // thread the diff cannot place is worked out from the diff itself rather than claimed
579 // here.
580 isOutdated: false,
581 comments: notes.map((note) => ({
582 id: String(note.id),
583 author: toActor(note.author),
584 body: note.body ?? "",
585 createdAt: note.created_at,
586 url: null,
587 })),
588 });
589 }
590 return Result.succeed({ threads, rawCount: decoded.success.length });
591}
592
593export function decodeDiffRefsJson(
594 raw: string,
595): Result.Result<GitLabDiffRefs | null, DecodeFailure> {
596 const decoded = decodeDiffRefs(raw);
597 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
598 const refs = decoded.success.diff_refs;
599 return Result.succeed(
600 refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null,
601 );
602}
603
604export function decodeNotesJson(
605 raw: string,
606): Result.Result<
607 { readonly comments: ReadonlyArray<PullRequestComment>; readonly rawCount: number },
608 DecodeFailure
609> {
610 const decoded = decodeUnknownList(raw);
611 if (!Result.isSuccess(decoded)) {
612 return Result.fail(decoded.failure);
613 }
614 const comments: PullRequestComment[] = [];
615 for (const entry of decoded.success) {
616 const note = decodeNoteEntry(entry);
617 if (Exit.isFailure(note)) continue;
618 const value = note.value;
619 if (value.system === true) continue;
620 const body = value.body ?? "";
621 if (body.trim().length === 0) continue;
622 const isDiffNote = value.type?.trim() === "DiffNote";
623 comments.push({
624 id: String(value.id),
625 kind: isDiffNote ? "review-comment" : "issue-comment",
626 author: toActor(value.author),
627 body,
628 createdAt: value.created_at,
629 url: null,
630 path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path),
631 reviewState: null,
632 });
633 }
634 return Result.succeed({ comments, rawCount: decoded.success.length });
635}
636
637export function decodeCommitsJson(
638 raw: string,
639): Result.Result<ReadonlyArray<PullRequestCommit>, DecodeFailure> {
640 const decoded = decodeUnknownList(raw);
641 if (!Result.isSuccess(decoded)) {
642 return Result.fail(decoded.failure);
643 }
644 const commits: PullRequestCommit[] = [];
645 for (const entry of decoded.success) {
646 const commit = decodeCommitEntry(entry);
647 if (Exit.isFailure(commit)) continue;
648 const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at);
649 if (committedDate === null) continue;
650 commits.push({
651 oid: commit.value.id,
652 messageHeadline: commit.value.title ?? "",
653 committedDate,
654 ...(commit.value.stats === null || commit.value.stats === undefined
655 ? {}
656 : {
657 additions: Math.max(0, commit.value.stats.additions ?? 0),
658 deletions: Math.max(0, commit.value.stats.deletions ?? 0),
659 }),
660 authors: (() => {
661 const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email);
662 return login === null
663 ? []
664 : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }];
665 })(),
666 });
667 }
668 // GitLab lists a merge request's commits newest first; the timeline reads oldest first.
669 return Result.succeed(commits.toReversed());
670}
671
672/** The exact comparison GitLab uses for a commit-scoped diff. */
673export function decodeCommitDiffRefsJson(
674 raw: string,
675): Result.Result<GitLabDiffRefs | null, DecodeFailure> {
676 const decoded = decodeCommit(raw);
677 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
678 const baseSha = trimmed(decoded.success.parent_ids?.[0]);
679 const headSha = trimmed(decoded.success.id);
680 return Result.succeed(
681 baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha },
682 );
683}
684
685function diffHeaderPaths(raw: Schema.Schema.Type<typeof RawDiffSchema>): {
686 readonly from: string;
687 readonly to: string;
688} {
689 return {
690 from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`),
691 to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`),
692 };
693}
694
695export interface GitLabMergeRequestPatch {
696 readonly patch: string;
697 /** At least one file's hunks were withheld by GitLab as too large to inline. */
698 readonly truncated: boolean;
699 /** Files GitLab returned, counted before decoding, so the caller can page. */
700 readonly rawCount: number;
701}
702
703/**
704 * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff
705 * viewer expects is assembled here. This decodes one page; walking pages is the caller's job,
706 * which is why the raw file count comes back with the patch.
707 */
708export function decodeMergeRequestDiffsJson(
709 raw: string,
710): Result.Result<GitLabMergeRequestPatch, DecodeFailure> {
711 const decoded = decodeUnknownList(raw);
712 if (!Result.isSuccess(decoded)) {
713 return Result.fail(decoded.failure);
714 }
715 const sections: string[] = [];
716 let truncated = false;
717 for (const entry of decoded.success) {
718 const file = decodeDiffEntry(entry);
719 if (Exit.isFailure(file)) continue;
720 const value = file.value;
721 const hunks = value.diff ?? "";
722 if (hunks.length === 0) {
723 // A file GitLab declined to inline still belongs in the file list, header only.
724 truncated = truncated || value.too_large === true || value.collapsed === true;
725 }
726 const { from, to } = diffHeaderPaths(value);
727 const header = [
728 `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`,
729 ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []),
730 ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []),
731 ...(value.renamed_file === true
732 ? [
733 `rename from ${quoteGitPatchPath(value.old_path)}`,
734 `rename to ${quoteGitPatchPath(value.new_path)}`,
735 ]
736 : []),
737 `--- ${from}`,
738 `+++ ${to}`,
739 ].join("\n");
740 sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`);
741 }
742 return Result.succeed({
743 patch: sections.join("\n"),
744 truncated,
745 rawCount: decoded.success.length,
746 });
747}
748
749/** GitLab's award names for the eight reactions the contract carries. */
750const GITLAB_AWARD_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = {
751 "thumbs-up": "thumbsup",
752 "thumbs-down": "thumbsdown",
753 laugh: "laughing",
754 hooray: "tada",
755 confused: "confused",
756 heart: "heart",
757 rocket: "rocket",
758 eyes: "eyes",
759};
760
761const CONTENT_BY_GITLAB_AWARD: Readonly<Record<string, PullRequestReactionContent>> =
762 Object.fromEntries(
763 Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]),
764 ) as Readonly<Record<string, PullRequestReactionContent>>;
765
766export function gitLabAwardName(content: PullRequestReactionContent): string {
767 return GITLAB_AWARD_BY_CONTENT[content];
768}
769
770/**
771 * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the
772 * conversation comes from carries no award at all, and asking per note would be a request each.
773 *
774 * `currentUser` rides along because GitLab names who awarded but never says whether that is the
775 * reader — so the comparison is made here rather than paid for with a request of its own.
776 */
777export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) {
778 currentUser { username }
779 project(fullPath: $fullPath) {
780 mergeRequest(iid: $iid) {
781 awardEmoji { nodes { name user { username } } }
782 notes(first: 100, after: $cursor) {
783 pageInfo { hasNextPage endCursor }
784 nodes { id awardEmoji { nodes { name user { username } } } }
785 }
786 }
787 }
788}`;
789
790const RawAwardEmojiNodesSchema = Schema.optional(
791 Schema.NullOr(
792 Schema.Struct({
793 nodes: Schema.optional(
794 Schema.NullOr(
795 Schema.Array(
796 Schema.NullOr(
797 Schema.Struct({
798 name: Schema.optional(Schema.NullOr(Schema.String)),
799 user: Schema.optional(
800 Schema.NullOr(
801 Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }),
802 ),
803 ),
804 }),
805 ),
806 ),
807 ),
808 ),
809 }),
810 ),
811);
812
813const RawAwardEmojiPageSchema = Schema.Struct({
814 data: Schema.Struct({
815 currentUser: Schema.optional(
816 Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })),
817 ),
818 project: Schema.NullOr(
819 Schema.Struct({
820 mergeRequest: Schema.NullOr(
821 Schema.Struct({
822 awardEmoji: RawAwardEmojiNodesSchema,
823 notes: Schema.optional(
824 Schema.NullOr(
825 Schema.Struct({
826 pageInfo: Schema.optional(
827 Schema.Struct({
828 hasNextPage: Schema.optional(Schema.Boolean),
829 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
830 }),
831 ),
832 nodes: Schema.Array(
833 Schema.NullOr(
834 Schema.Struct({
835 id: Schema.optional(Schema.NullOr(Schema.String)),
836 awardEmoji: RawAwardEmojiNodesSchema,
837 }),
838 ),
839 ),
840 }),
841 ),
842 ),
843 }),
844 ),
845 }),
846 ),
847 }),
848});
849
850const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema);
851
852/**
853 * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username
854 * is left out of `actors` — the page names them "You" instead, and leaving it in would name them
855 * twice — but `count` still counts them along with everyone else.
856 */
857function toReactions(
858 nodes: Schema.Schema.Type<typeof RawAwardEmojiNodesSchema>,
859 viewer: string | null,
860): ReadonlyArray<PullRequestReaction> {
861 const normalizedViewer = viewer?.toLowerCase() ?? null;
862 const groups = new Map<
863 PullRequestReactionContent,
864 { count: number; actors: string[]; viewer: boolean }
865 >();
866 for (const node of nodes?.nodes ?? []) {
867 // An award outside the eight is left out rather than shown under a name the picker has no
868 // way to take back: GitLab accepts any emoji, and the other hosts accept none of them.
869 const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""];
870 if (content === undefined) continue;
871 const username = trimmed(node?.user?.username);
872 if (username === null) continue;
873 const group = groups.get(content) ?? { count: 0, actors: [], viewer: false };
874 group.count++;
875 if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) {
876 group.viewer = true;
877 } else {
878 group.actors.push(username);
879 }
880 groups.set(content, group);
881 }
882 return [...groups].flatMap(([content, group]) =>
883 group.count === 0
884 ? []
885 : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }],
886 );
887}
888
889/** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */
890function noteIdOf(gid: string | null | undefined): string | null {
891 const id = trimmed(gid)?.split("/").at(-1);
892 return id !== undefined && /^\d+$/.test(id) ? id : null;
893}
894
895export interface GitLabAwardEmojiPage {
896 /** The merge request's own awards, which are the ones on its description. */
897 readonly reactions: ReadonlyArray<PullRequestReaction>;
898 readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
899 readonly nextCursor: string | null;
900}
901
902export function decodeAwardEmojiJson(
903 raw: string,
904): Result.Result<GitLabAwardEmojiPage, DecodeFailure> {
905 const decoded = decodeAwardEmojiPage(raw);
906 if (!Result.isSuccess(decoded)) {
907 return Result.fail(decoded.failure);
908 }
909 const data = decoded.success.data;
910 const viewer = trimmed(data.currentUser?.username);
911 const mergeRequest = data.project?.mergeRequest;
912 const reactionsByNoteId = new Map<string, ReadonlyArray<PullRequestReaction>>();
913 for (const node of mergeRequest?.notes?.nodes ?? []) {
914 const id = noteIdOf(node?.id);
915 if (id === null) continue;
916 const reactions = toReactions(node?.awardEmoji, viewer);
917 if (reactions.length > 0) reactionsByNoteId.set(id, reactions);
918 }
919 const pageInfo = mergeRequest?.notes?.pageInfo;
920 return Result.succeed({
921 reactions: toReactions(mergeRequest?.awardEmoji, viewer),
922 reactionsByNoteId,
923 nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null,
924 });
925}
926
927const RawAwardSchema = Schema.Struct({
928 id: Schema.Int,
929 name: Schema.optional(Schema.NullOr(Schema.String)),
930 user: Schema.optional(
931 Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })),
932 ),
933});
934
935const decodeAward = Schema.decodeUnknownExit(RawAwardSchema);
936
937/**
938 * The reader's own award of one name on a subject, which is what taking a reaction back is
939 * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji.
940 */
941export function decodeOwnAwardIdJson(
942 raw: string,
943 input: { readonly content: PullRequestReactionContent; readonly viewer: string },
944): Result.Result<number | null, DecodeFailure> {
945 const decoded = decodeUnknownList(raw);
946 if (!Result.isSuccess(decoded)) {
947 return Result.fail(decoded.failure);
948 }
949 const name = gitLabAwardName(input.content);
950 for (const entry of decoded.success) {
951 const award = decodeAward(entry);
952 if (Exit.isFailure(award)) continue;
953 const value = award.value;
954 if (trimmed(value.name)?.toLowerCase() !== name) continue;
955 if (trimmed(value.user?.username) !== input.viewer) continue;
956 return Result.succeed(value.id);
957 }
958 return Result.succeed(null);
959}
960
961/**
962 * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking
963 * the tree, since GitLab charges this query by how many paths it is given. A path the revision
964 * does not have comes back missing rather than as an error, which is the answer for a deleted file.
965 */
966export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) {
967 project(fullPath: $fullPath) {
968 repository {
969 blobs(ref: $ref, paths: $paths) {
970 nodes { path oid }
971 }
972 }
973 }
974}`;
975
976const RawRepositoryBlobsSchema = Schema.Struct({
977 data: Schema.Struct({
978 project: Schema.NullOr(
979 Schema.Struct({
980 repository: Schema.optional(
981 Schema.NullOr(
982 Schema.Struct({
983 blobs: Schema.optional(
984 Schema.NullOr(
985 Schema.Struct({
986 nodes: Schema.optional(
987 Schema.NullOr(
988 Schema.Array(
989 Schema.NullOr(
990 Schema.Struct({
991 path: Schema.optional(Schema.NullOr(Schema.String)),
992 oid: Schema.optional(Schema.NullOr(Schema.String)),
993 }),
994 ),
995 ),
996 ),
997 ),
998 }),
999 ),
1000 ),
1001 }),
1002 ),
1003 ),
1004 }),
1005 ),
1006 }),
1007});
1008
1009const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema);
1010
1011/**
1012 * Blob ids by path, or null where GitLab did not answer the query at all (a project the token
1013 * cannot see, or a repository with no blobs connection). That case must be told apart from an
1014 * empty answer: read as "the revision has none of these files", it would report every cleared
1015 * file as changed again. A node missing either half is left out, since the caller treats an
1016 * absent path as one the revision does not carry.
1017 */
1018export function decodeRepositoryBlobsJson(
1019 raw: string,
1020): Result.Result<ReadonlyMap<string, string> | null, DecodeFailure> {
1021 const decoded = decodeRepositoryBlobs(raw);
1022 if (!Result.isSuccess(decoded)) {
1023 return Result.fail(decoded.failure);
1024 }
1025 const nodes = decoded.success.data.project?.repository?.blobs?.nodes;
1026 if (nodes === undefined || nodes === null) return Result.succeed(null);
1027 const blobs = new Map<string, string>();
1028 for (const node of nodes) {
1029 // Not trimmed, unlike everything else read out of this payload: a leading or trailing space
1030 // is a legal part of a file's name, and trimming it would key this map under a name the
1031 // caller's asked-for path never matches.
1032 const path = node?.path;
1033 const oid = trimmed(node?.oid);
1034 if (path === undefined || path === null || path.length === 0 || oid === null) continue;
1035 blobs.set(path, oid);
1036 }
1037 return Result.succeed(blobs);
1038}