const decoded = decodeProjectMergeSettings(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); const squashOption = decoded.success.squash_option?.trim().toLowerCase(); return Result.succeed({ merge: mergeMethod === "merge", // Both semi-linear and fast-forward histories are reached by rebasing onto the target. rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, // rather than offering a squash the project may forbid. squash: squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", });} /** * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the * root of a line-level discussion, which is what the review-comment kind means. * * The raw note count comes back alongside, because dropping notes hides whether the page was * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. *//** The three revisions a positioned comment is written against. */export interface GitLabDiffRefs { readonly baseSha: string; readonly headSha: string; readonly startSha: string;} export interface GitLabDiscussions { readonly threads: ReadonlyArray<PullRequestReviewThread>; /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */ readonly rawCount: number;} /** * Positioned discussions only. GitLab returns the merge request's whole conversation here, * including the plain notes the timeline already shows, and only a positioned one belongs * against a line of the diff. */export function decodeDiscussionsJson( raw: string,): Result.Result<GitLabDiscussions, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const threads: PullRequestReviewThread[] = []; for (const entry of decoded.success) { const discussion = decodeDiscussionEntry(entry); if (!Exit.isSuccess(discussion)) continue; const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); const root = notes[0]; const position = root?.position; if (root === undefined || !position || position.position_type !== "text") continue; // A comment on an added or context line carries `new_line`; one on a removed line carries // only `old_line`, and belongs against the file as it was. const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; const path = trimmed(side === "left" ? position.old_path : position.new_path); const line = side === "left" ? position.old_line : position.new_line; if (path === null) continue; threads.push({ id: discussion.value.id, path, line: typeof line === "number" && line > 0 ? line : null, side, isResolved: root.resolved === true, // GitLab reports no equivalent of "written against a line that has since moved", so a // thread the diff cannot place is worked out from the diff itself rather than claimed // here. isOutdated: false, comments: notes.map((note) => ({ id: String(note.id), author: toActor(note.author), body: note.body ?? "", createdAt: note.created_at, url: null, })), }); } return Result.succeed({ threads, rawCount: decoded.success.length });} export function decodeDiffRefsJson( raw: string,): Result.Result<GitLabDiffRefs | null, DecodeFailure> { const decoded = decodeDiffRefs(raw); if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); const refs = decoded.success.diff_refs; return Result.succeed( refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null, );} export function decodeNotesJson( raw: string,): Result.Result< { readonly comments: ReadonlyArray<PullRequestComment>; readonly rawCount: number }, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const comments: PullRequestComment[] = []; for (const entry of decoded.success) { const note = decodeNoteEntry(entry); if (Exit.isFailure(note)) continue; const value = note.value; if (value.system === true) continue; const body = value.body ?? ""; if (body.trim().length === 0) continue; const isDiffNote = value.type?.trim() === "DiffNote"; comments.push({ id: String(value.id), kind: isDiffNote ? "review-comment" : "issue-comment", author: toActor(value.author), body, createdAt: value.created_at, url: null, path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path), reviewState: null, }); } return Result.succeed({ comments, rawCount: decoded.success.length });} export function decodeCommitsJson( raw: string,): Result.Result<ReadonlyArray<PullRequestCommit>, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const commits: PullRequestCommit[] = []; for (const entry of decoded.success) { const commit = decodeCommitEntry(entry); if (Exit.isFailure(commit)) continue; const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at); if (committedDate === null) continue; commits.push({ oid: commit.value.id, messageHeadline: commit.value.title ?? "", committedDate, ...(commit.value.stats === null || commit.value.stats === undefined ? {} : { additions: Math.max(0, commit.value.stats.additions ?? 0), deletions: Math.max(0, commit.value.stats.deletions ?? 0), }), authors: (() => { const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email); return login === null ? [] : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }]; })(), }); } // GitLab lists a merge request's commits newest first; the timeline reads oldest first. return Result.succeed(commits.toReversed());} /** The exact comparison GitLab uses for a commit-scoped diff. */export function decodeCommitDiffRefsJson( raw: string,): Result.Result<GitLabDiffRefs | null, DecodeFailure> { const decoded = decodeCommit(raw); if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); const baseSha = trimmed(decoded.success.parent_ids?.[0]); const headSha = trimmed(decoded.success.id); return Result.succeed( baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha }, );} function diffHeaderPaths(raw: Schema.Schema.Type<typeof RawDiffSchema>): { readonly from: string; readonly to: string;} { return { from: raw.new_file === true ? "/dev/null" : `a/${raw.old_path}`, to: raw.deleted_file === true ? "/dev/null" : `b/${raw.new_path}`, };} export interface GitLabMergeRequestPatch { readonly patch: string; /** At least one file's hunks were withheld by GitLab as too large to inline. */ readonly truncated: boolean; /** Files GitLab returned, counted before decoding, so the caller can page. */ readonly rawCount: number;} /** * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, * which is why the raw file count comes back with the patch. */export function decodeMergeRequestDiffsJson( raw: string,): Result.Result<GitLabMergeRequestPatch, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const sections: string[] = []; let truncated = false; for (const entry of decoded.success) { const file = decodeDiffEntry(entry); if (Exit.isFailure(file)) continue; const value = file.value; const hunks = value.diff ?? ""; if (hunks.length === 0) { // A file GitLab declined to inline still belongs in the file list, header only. truncated = truncated || value.too_large === true || value.collapsed === true; } const { from, to } = diffHeaderPaths(value); const header = [ `diff --git a/${value.old_path} b/${value.new_path}`, ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), ...(value.renamed_file === true ? [`rename from ${value.old_path}`, `rename to ${value.new_path}`] : []), `--- ${from}`, `+++ ${to}`, ].join("\n"); sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); } return Result.succeed({ patch: sections.join("\n"), truncated, rawCount: decoded.success.length, });} /** GitLab's award names for the eight reactions the contract carries. */const GITLAB_AWARD_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = { "thumbs-up": "thumbsup", "thumbs-down": "thumbsdown", laugh: "laughing", hooray: "tada", confused: "confused", heart: "heart", rocket: "rocket", eyes: "eyes",}; const CONTENT_BY_GITLAB_AWARD: Readonly<Record<string, PullRequestReactionContent>> = Object.fromEntries( Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]), ) as Readonly<Record<string, PullRequestReactionContent>>; export function gitLabAwardName(content: PullRequestReactionContent): string { return GITLAB_AWARD_BY_CONTENT[content];} /** * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the * conversation comes from carries no award at all, and asking per note would be a request each. * * `currentUser` rides along because GitLab names who awarded but never says whether that is the * reader — so the comparison is made here rather than paid for with a request of its own. */export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { currentUser { username } project(fullPath: $fullPath) { mergeRequest(iid: $iid) { awardEmoji { nodes { name user { username } } } notes(first: 100, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { id awardEmoji { nodes { name user { username } } } } } } }}`; const RawAwardEmojiNodesSchema = Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.optional( Schema.NullOr( Schema.Array( Schema.NullOr( Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), user: Schema.optional( Schema.NullOr( Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }), ), ), }), ), ), ), ), }), ),); const RawAwardEmojiPageSchema = Schema.Struct({ data: Schema.Struct({ currentUser: Schema.optional( Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), ), project: Schema.NullOr( Schema.Struct({ mergeRequest: Schema.NullOr( Schema.Struct({ awardEmoji: RawAwardEmojiNodesSchema, notes: Schema.optional( Schema.NullOr( Schema.Struct({ pageInfo: Schema.optional( Schema.Struct({ hasNextPage: Schema.optional(Schema.Boolean), endCursor: Schema.optional(Schema.NullOr(Schema.String)), }), ), nodes: Schema.Array( Schema.NullOr( Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)), awardEmoji: RawAwardEmojiNodesSchema, }), ), ), }), ), ), }), ), }), ), }),}); const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema); /** * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username * is left out of `actors` — the page names them "You" instead, and leaving it in would name them * twice — but `count` still counts them along with everyone else. */function toReactions( nodes: Schema.Schema.Type<typeof RawAwardEmojiNodesSchema>, viewer: string | null,): ReadonlyArray<PullRequestReaction> { const normalizedViewer = viewer?.toLowerCase() ?? null; const groups = new Map< PullRequestReactionContent, { count: number; actors: string[]; viewer: boolean } >(); for (const node of nodes?.nodes ?? []) { // An award outside the eight is left out rather than shown under a name the picker has no // way to take back: GitLab accepts any emoji, and the other hosts accept none of them. const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; if (content === undefined) continue; const username = trimmed(node?.user?.username); if (username === null) continue; const group = groups.get(content) ?? { count: 0, actors: [], viewer: false }; group.count++; if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) { group.viewer = true; } else { group.actors.push(username); } groups.set(content, group); } return [...groups].flatMap(([content, group]) => group.count === 0 ? [] : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }], );} /** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */function noteIdOf(gid: string | null | undefined): string | null { const id = trimmed(gid)?.split("/").at(-1); return id !== undefined && /^\d+$/.test(id) ? id : null;} export interface GitLabAwardEmojiPage { /** The merge request's own awards, which are the ones on its description. */ readonly reactions: ReadonlyArray<PullRequestReaction>; readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; readonly nextCursor: string | null;} export function decodeAwardEmojiJson( raw: string,): Result.Result<GitLabAwardEmojiPage, DecodeFailure> { const decoded = decodeAwardEmojiPage(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const data = decoded.success.data; const viewer = trimmed(data.currentUser?.username); const mergeRequest = data.project?.mergeRequest; const reactionsByNoteId = new Map<string, ReadonlyArray<PullRequestReaction>>(); for (const node of mergeRequest?.notes?.nodes ?? []) { const id = noteIdOf(node?.id); if (id === null) continue; const reactions = toReactions(node?.awardEmoji, viewer); if (reactions.length > 0) reactionsByNoteId.set(id, reactions); } const pageInfo = mergeRequest?.notes?.pageInfo; return Result.succeed({ reactions: toReactions(mergeRequest?.awardEmoji, viewer), reactionsByNoteId, nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null, });} const RawAwardSchema = Schema.Struct({ id: Schema.Int, name: Schema.optional(Schema.NullOr(Schema.String)), user: Schema.optional( Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), ),}); const decodeAward = Schema.decodeUnknownExit(RawAwardSchema); /** * The reader's own award of one name on a subject, which is what taking a reaction back is * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji. */export function decodeOwnAwardIdJson( raw: string, input: { readonly content: PullRequestReactionContent; readonly viewer: string },): Result.Result<number | null, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const name = gitLabAwardName(input.content); for (const entry of decoded.success) { const award = decodeAward(entry); if (Exit.isFailure(award)) continue; const value = award.value; if (trimmed(value.name)?.toLowerCase() !== name) continue; if (trimmed(value.user?.username) !== input.viewer) continue; return Result.succeed(value.id); } return Result.succeed(null);}Lines 501–949 of 949. Previous lines
const decoded = decodeProjectMergeSettings(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); const squashOption = decoded.success.squash_option?.trim().toLowerCase(); return Result.succeed({ merge: mergeMethod === "merge", // Both semi-linear and fast-forward histories are reached by rebasing onto the target. rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, // rather than offering a squash the project may forbid. squash: squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", }); } /** * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the * root of a line-level discussion, which is what the review-comment kind means. * * The raw note count comes back alongside, because dropping notes hides whether the page was * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. */ /** The three revisions a positioned comment is written against. */ export interface GitLabDiffRefs { readonly baseSha: string; readonly headSha: string; readonly startSha: string; } export interface GitLabDiscussions { readonly threads: ReadonlyArray<PullRequestReviewThread>; /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */ readonly rawCount: number; } /** * Positioned discussions only. GitLab returns the merge request's whole conversation here, * including the plain notes the timeline already shows, and only a positioned one belongs * against a line of the diff. */ export function decodeDiscussionsJson( raw: string, ): Result.Result<GitLabDiscussions, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const threads: PullRequestReviewThread[] = []; for (const entry of decoded.success) { const discussion = decodeDiscussionEntry(entry); if (!Exit.isSuccess(discussion)) continue; const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); const root = notes[0]; const position = root?.position; if (root === undefined || !position || position.position_type !== "text") continue; // A comment on an added or context line carries `new_line`; one on a removed line carries // only `old_line`, and belongs against the file as it was. const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; const path = trimmed(side === "left" ? position.old_path : position.new_path); const line = side === "left" ? position.old_line : position.new_line; if (path === null) continue; threads.push({ id: discussion.value.id, path, line: typeof line === "number" && line > 0 ? line : null, side, isResolved: root.resolved === true, // GitLab reports no equivalent of "written against a line that has since moved", so a // thread the diff cannot place is worked out from the diff itself rather than claimed // here. isOutdated: false, comments: notes.map((note) => ({ id: String(note.id), author: toActor(note.author), body: note.body ?? "", createdAt: note.created_at, url: null, })), }); } return Result.succeed({ threads, rawCount: decoded.success.length }); } export function decodeDiffRefsJson( raw: string, ): Result.Result<GitLabDiffRefs | null, DecodeFailure> { const decoded = decodeDiffRefs(raw); if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); const refs = decoded.success.diff_refs; return Result.succeed( refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null, ); } export function decodeNotesJson( raw: string, ): Result.Result< { readonly comments: ReadonlyArray<PullRequestComment>; readonly rawCount: number }, DecodeFailure > { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const comments: PullRequestComment[] = []; for (const entry of decoded.success) { const note = decodeNoteEntry(entry); if (Exit.isFailure(note)) continue; const value = note.value; if (value.system === true) continue; const body = value.body ?? ""; if (body.trim().length === 0) continue; const isDiffNote = value.type?.trim() === "DiffNote"; comments.push({ id: String(value.id), kind: isDiffNote ? "review-comment" : "issue-comment", author: toActor(value.author), body, createdAt: value.created_at, url: null, path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path), reviewState: null, }); } return Result.succeed({ comments, rawCount: decoded.success.length }); } export function decodeCommitsJson( raw: string, ): Result.Result<ReadonlyArray<PullRequestCommit>, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const commits: PullRequestCommit[] = []; for (const entry of decoded.success) { const commit = decodeCommitEntry(entry); if (Exit.isFailure(commit)) continue; const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at); if (committedDate === null) continue; commits.push({ oid: commit.value.id, messageHeadline: commit.value.title ?? "", committedDate, ...(commit.value.stats === null || commit.value.stats === undefined ? {} : { additions: Math.max(0, commit.value.stats.additions ?? 0), deletions: Math.max(0, commit.value.stats.deletions ?? 0), }), authors: (() => { const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email); return login === null ? [] : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }]; })(), }); } // GitLab lists a merge request's commits newest first; the timeline reads oldest first. return Result.succeed(commits.toReversed()); } /** The exact comparison GitLab uses for a commit-scoped diff. */ export function decodeCommitDiffRefsJson( raw: string, ): Result.Result<GitLabDiffRefs | null, DecodeFailure> { const decoded = decodeCommit(raw); if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); const baseSha = trimmed(decoded.success.parent_ids?.[0]); const headSha = trimmed(decoded.success.id); return Result.succeed( baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha }, ); } function diffHeaderPaths(raw: Schema.Schema.Type<typeof RawDiffSchema>): { readonly from: string; readonly to: string; } { return { from: raw.new_file === true ? "/dev/null" : `a/${raw.old_path}`, to: raw.deleted_file === true ? "/dev/null" : `b/${raw.new_path}`, }; } export interface GitLabMergeRequestPatch { readonly patch: string; /** At least one file's hunks were withheld by GitLab as too large to inline. */ readonly truncated: boolean; /** Files GitLab returned, counted before decoding, so the caller can page. */ readonly rawCount: number; } /** * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, * which is why the raw file count comes back with the patch. */ export function decodeMergeRequestDiffsJson( raw: string, ): Result.Result<GitLabMergeRequestPatch, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const sections: string[] = []; let truncated = false; for (const entry of decoded.success) { const file = decodeDiffEntry(entry); if (Exit.isFailure(file)) continue; const value = file.value; const hunks = value.diff ?? ""; if (hunks.length === 0) { // A file GitLab declined to inline still belongs in the file list, header only. truncated = truncated || value.too_large === true || value.collapsed === true; } const { from, to } = diffHeaderPaths(value); const header = [ `diff --git a/${value.old_path} b/${value.new_path}`, ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), ...(value.renamed_file === true ? [`rename from ${value.old_path}`, `rename to ${value.new_path}`] : []), `--- ${from}`, `+++ ${to}`, ].join("\n"); sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); } return Result.succeed({ patch: sections.join("\n"), truncated, rawCount: decoded.success.length, }); } /** GitLab's award names for the eight reactions the contract carries. */ const GITLAB_AWARD_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = { "thumbs-up": "thumbsup", "thumbs-down": "thumbsdown", laugh: "laughing", hooray: "tada", confused: "confused", heart: "heart", rocket: "rocket", eyes: "eyes", }; const CONTENT_BY_GITLAB_AWARD: Readonly<Record<string, PullRequestReactionContent>> = Object.fromEntries( Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]), ) as Readonly<Record<string, PullRequestReactionContent>>; export function gitLabAwardName(content: PullRequestReactionContent): string { return GITLAB_AWARD_BY_CONTENT[content]; } /** * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the * conversation comes from carries no award at all, and asking per note would be a request each. * * `currentUser` rides along because GitLab names who awarded but never says whether that is the * reader — so the comparison is made here rather than paid for with a request of its own. */ export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { currentUser { username } project(fullPath: $fullPath) { mergeRequest(iid: $iid) { awardEmoji { nodes { name user { username } } } notes(first: 100, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { id awardEmoji { nodes { name user { username } } } } } } } }`; const RawAwardEmojiNodesSchema = Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.optional( Schema.NullOr( Schema.Array( Schema.NullOr( Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), user: Schema.optional( Schema.NullOr( Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }), ), ), }), ), ), ), ), }), ), ); const RawAwardEmojiPageSchema = Schema.Struct({ data: Schema.Struct({ currentUser: Schema.optional( Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), ), project: Schema.NullOr( Schema.Struct({ mergeRequest: Schema.NullOr( Schema.Struct({ awardEmoji: RawAwardEmojiNodesSchema, notes: Schema.optional( Schema.NullOr( Schema.Struct({ pageInfo: Schema.optional( Schema.Struct({ hasNextPage: Schema.optional(Schema.Boolean), endCursor: Schema.optional(Schema.NullOr(Schema.String)), }), ), nodes: Schema.Array( Schema.NullOr( Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)), awardEmoji: RawAwardEmojiNodesSchema, }), ), ), }), ), ), }), ), }), ), }), }); const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema); /** * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username * is left out of `actors` — the page names them "You" instead, and leaving it in would name them * twice — but `count` still counts them along with everyone else. */ function toReactions( nodes: Schema.Schema.Type<typeof RawAwardEmojiNodesSchema>, viewer: string | null, ): ReadonlyArray<PullRequestReaction> { const normalizedViewer = viewer?.toLowerCase() ?? null; const groups = new Map< PullRequestReactionContent, { count: number; actors: string[]; viewer: boolean } >(); for (const node of nodes?.nodes ?? []) { // An award outside the eight is left out rather than shown under a name the picker has no // way to take back: GitLab accepts any emoji, and the other hosts accept none of them. const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; if (content === undefined) continue; const username = trimmed(node?.user?.username); if (username === null) continue; const group = groups.get(content) ?? { count: 0, actors: [], viewer: false }; group.count++; if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) { group.viewer = true; } else { group.actors.push(username); } groups.set(content, group); } return [...groups].flatMap(([content, group]) => group.count === 0 ? [] : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }], ); } /** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */ function noteIdOf(gid: string | null | undefined): string | null { const id = trimmed(gid)?.split("/").at(-1); return id !== undefined && /^\d+$/.test(id) ? id : null; } export interface GitLabAwardEmojiPage { /** The merge request's own awards, which are the ones on its description. */ readonly reactions: ReadonlyArray<PullRequestReaction>; readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; readonly nextCursor: string | null; } export function decodeAwardEmojiJson( raw: string, ): Result.Result<GitLabAwardEmojiPage, DecodeFailure> { const decoded = decodeAwardEmojiPage(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const data = decoded.success.data; const viewer = trimmed(data.currentUser?.username); const mergeRequest = data.project?.mergeRequest; const reactionsByNoteId = new Map<string, ReadonlyArray<PullRequestReaction>>(); for (const node of mergeRequest?.notes?.nodes ?? []) { const id = noteIdOf(node?.id); if (id === null) continue; const reactions = toReactions(node?.awardEmoji, viewer); if (reactions.length > 0) reactionsByNoteId.set(id, reactions); } const pageInfo = mergeRequest?.notes?.pageInfo; return Result.succeed({ reactions: toReactions(mergeRequest?.awardEmoji, viewer), reactionsByNoteId, nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null, }); } const RawAwardSchema = Schema.Struct({ id: Schema.Int, name: Schema.optional(Schema.NullOr(Schema.String)), user: Schema.optional( Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), ), }); const decodeAward = Schema.decodeUnknownExit(RawAwardSchema); /** * The reader's own award of one name on a subject, which is what taking a reaction back is * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji. */ export function decodeOwnAwardIdJson( raw: string, input: { readonly content: PullRequestReactionContent; readonly viewer: string }, ): Result.Result<number | null, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const name = gitLabAwardName(input.content); for (const entry of decoded.success) { const award = decodeAward(entry); if (Exit.isFailure(award)) continue; const value = award.value; if (trimmed(value.name)?.toLowerCase() !== name) continue; if (trimmed(value.user?.username) !== input.viewer) continue; return Result.succeed(value.id); } return Result.succeed(null); }