949 lines · 35.2 KB · TypeScript
RawDownload
  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

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t