Greasy Fork is available in English.

gitlab-booster

Boost productivity for code reviewers on gitlab

// ==UserScript==
// @name         gitlab-booster
// @namespace    vite-plugin-monkey
// @version      1.2.2
// @author
// @description  Boost productivity for code reviewers on gitlab
// @license      AGPL-3.0-or-later
// @icon         https://www.google.com/s2/favicons?sz=64&domain=gitlab.com
// @homepage     https://github.com/braineo/gitlab-booster#readme
// @homepageURL  https://github.com/braineo/gitlab-booster#readme
// @source       https://github.com/braineo/gitlab-booster.git
// @supportURL   https://github.com/braineo/gitlab-booster/issues
// @match        https://gitlab.com/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js
// @grant        GM_addElement
// @grant        window.onurlchange
// ==/UserScript==

(function ($) {
  'use strict';

  var _GM_addElement = /* @__PURE__ */ (() => typeof GM_addElement != "undefined" ? GM_addElement : void 0)();
  const waitForKeyElements = function(selectorOrFunction, callback, waitOnce, interval, maxIntervals) {
    if (typeof waitOnce === "undefined") {
      waitOnce = true;
    }
    if (typeof interval === "undefined") {
      interval = 300;
    }
    if (typeof maxIntervals === "undefined") {
      maxIntervals = -1;
    }
    if (typeof waitForKeyElements.namespace === "undefined") {
      waitForKeyElements.namespace = Date.now().toString();
    }
    var targetNodes = typeof selectorOrFunction === "function" ? selectorOrFunction() : document.querySelectorAll(selectorOrFunction);
    var targetsFound = targetNodes && targetNodes.length > 0;
    if (targetsFound) {
      targetNodes.forEach(function(targetNode) {
        var attrAlreadyFound = `data-userscript-${waitForKeyElements.namespace}-alreadyFound`;
        var alreadyFound = targetNode.getAttribute(attrAlreadyFound) || false;
        if (!alreadyFound) {
          var cancelFound = callback(targetNode);
          if (cancelFound) {
            targetsFound = false;
          } else {
            targetNode.setAttribute(attrAlreadyFound, "true");
          }
        }
      });
    }
    if (maxIntervals !== 0 && !(targetsFound && waitOnce)) {
      maxIntervals -= 1;
      setTimeout(function() {
        waitForKeyElements(
          selectorOrFunction,
          callback,
          waitOnce,
          interval,
          maxIntervals
        );
      }, interval);
    }
  };
  _GM_addElement("link", {
    rel: "stylesheet",
    href: "https://cdn.jsdelivr.net/npm/[email protected]/nf-font.min.css"
  });
  const getApiUrl = (url) => {
    return `${window.location.origin}/api/v4${url}`;
  };
  async function fetchGitLabData(url) {
    const response = await fetch(url, {
      headers: { "Content-Type": "application/json" }
    });
    if (!response.ok) {
      console.error("Failed to fetch GitLab data:", response.statusText);
      return null;
    }
    return await response.json();
  }
  let currentUser;
  function createThreadsBadge(element, badgeClassName, resolved, resolvable) {
    const li = $("<li/>").addClass("issuable-comments d-none d-sm-flex").prependTo(element);
    $("<span/>").addClass(
      `gl-badge badge badge-pill badge-${badgeClassName} sm has-tooltip`
    ).text(`${resolved}/${resolvable} threads resolved`).prependTo(li);
  }
  function createThreadActionBadges(element, action) {
    const li = $("<li/>").addClass("issuable-comments d-none d-sm-flex").prependTo(element);
    const createIconText = (icon, title, text, badgeClassName) => {
      return $("<span/>", {
        title,
        class: `gl-badge badge badge-pill ${badgeClassName ? `badge-${badgeClassName}` : ""} sm has-tooltip`
      }).css({
        "font-family": "SauceCodePro Mono"
      }).text(`${icon} ${text ?? ""}`);
    };
    if (action.waitForOursCount) {
      createIconText(
        "",
        "need your response",
        action.waitForOursCount.toString(),
        "danger"
      ).prependTo(li);
    }
    if (action.waitForTheirsCount) {
      createIconText(
        "",
        "wait for response",
        action.waitForTheirsCount.toString(),
        "muted"
      ).prependTo(li);
    }
    if (action.otherUnresolvedCount) {
      createIconText(
        "",
        "other threads",
        action.otherUnresolvedCount.toString(),
        "warning"
      ).prependTo(li);
    }
    if (action.needUserReview) {
      createIconText("", "need your review", void 0, "danger").prependTo(
        li
      );
    }
  }
  function createDiffStat(element, fileCount, addLineCount, deleteLinCount) {
    $("<div/>").css({ display: "flex", "flex-direction": "row", gap: "3px" }).append(
      $("<div/>", { class: "diff-stats-group" }).append(
        $("<span/>", {
          class: "gl-text-gray-500 bold",
          text: `${fileCount} files`
        })
      ),
      $("<div/>", {
        class: "diff-stats-group gl-text-green-600 gl-display-flex gl-align-items-center bold"
      }).append($("<span/>").text("+"), $("<span/>").text(`${addLineCount}`)),
      $("<div/>", {
        class: "diff-stats-group gl-text-red-500 gl-display-flex gl-align-items-center bold"
      }).append($("<span/>").text("-"), $("<span/>").text(`${deleteLinCount}`))
    ).prependTo(element);
  }
  function createIssueCardMergeRequestInfo(element, opened, total) {
    const inline = $("<span/>").appendTo(element);
    $("<div/>", {
      class: "issue-milestone-details gl-flex gl-max-w-15 gl-gap-2 gl-mr-3 gl-inline-flex gl-max-w-15 gl-cursor-help gl-items-center gl-align-bottom gl-text-sm gl-text-gray-500"
    }).append(
      $("<span/>", {
        title: "Merge requests"
      }).css({
        "font-family": "SauceCodePro Mono",
        "font-size": "1.1rem"
      }).text(""),
      $("<span/>", {
        class: "gl-inline-block gl-truncate gl-font-bold"
      }).text(total === 0 ? "-/-" : `${total - opened}/${total}`)
    ).appendTo(inline);
  }
  function ensurePanelLayout() {
    const layout = document.querySelector("div.layout-page");
    if (!layout) {
      return;
    }
    $(layout).css({ display: "flex", height: "100vh", overflow: "hidden" });
    const content = document.querySelector("div.content-wrapper");
    if (!content) {
      return;
    }
    $(content).css({ overflowY: "scroll" });
  }
  function ensureSidePanel(panelName, url) {
    const buttonId = `close-${panelName.toLowerCase().replaceAll(" ", "-")}`;
    if (!document.querySelector(`#${buttonId}`)) {
      const topBar = document.querySelector(".top-bar-container");
      if (!topBar) {
        return;
      }
      $(topBar).append(
        $("<button/>", {
          id: buttonId,
          class: "btn btn-default btn-md gl-button btn-close js-note-target-close btn-comment btn-comment-and-close"
        }).append($("<span/>").text(`Close ${panelName}`))
      );
      $(`#${buttonId}`).on("click", () => {
        $("#issue-booster").remove();
        $(`#${buttonId}`).remove();
      });
    }
    const layout = document.querySelector("div.layout-page");
    if (!layout) {
      return;
    }
    $("#issue-booster").remove();
    _GM_addElement(layout, "iframe", {
      id: "issue-booster",
      src: url,
      // @ts-ignore // typing says style is readonly
      style: (
        // make issue panel sticky
        "width: 100%; height: 100vh; position: sticky; align-self: flex-start; top: 0; flex: 0 0 40%;"
      )
    });
  }
  const openModal = (url) => {
    const modal = $("#gitlab-booster-modal");
    if (modal) {
      modal.remove();
    }
    const modalContent = $("<div/>", { class: "modal-content" }).append(
      $("<header/>", { class: "modal-header" }).append(
        $("<h2/>", { textContent: "Quick preview" }),
        $("<button/>", {
          class: "btn btn-default btn-md gl-button btn-close js-note-target-close btn-comment btn-comment-and-close"
        }).append($("<span/>").text("Close Modal")).on("click", () => {
          var _a;
          (_a = document.querySelector("#gitlab-booster-modal")) == null ? void 0 : _a.remove();
        })
      )
    );
    $("<div/>", {
      id: "gitlab-booster-modal",
      class: "modal fade show d-block gl-modal"
    }).append(
      $("<div/>", { class: "modal-dialog modal-lg" }).css({ "max-width": "80vw" }).append(modalContent)
    ).appendTo($("body"));
    const iframe = _GM_addElement(modalContent[0], "iframe", {
      id: "issue-booster",
      src: url
    });
    iframe.className = "modal-body";
    iframe.setAttribute("style", "height: 80vh;");
  };
  const getUser = async () => {
    return fetchGitLabData(getApiUrl("/user"));
  };
  async function addMergeRequestThreadMeta(element, mergeRequestUrl) {
    const discussions = await fetchGitLabData(
      `${mergeRequestUrl}/discussions.json`
    ) ?? [];
    let resolvable = 0;
    let resolved = 0;
    for (const discussion of discussions) {
      if (discussion.resolvable) {
        resolvable += 1;
      }
      if (discussion.resolved) {
        resolved += 1;
      }
    }
    const listItem = await fetchGitLabData(
      `${mergeRequestUrl}.json`
    );
    if (!currentUser) {
      currentUser = await getUser();
    }
    const userId = currentUser == null ? void 0 : currentUser.id;
    let renderFallback = true;
    if (listItem && userId) {
      const mergeRequest = await fetchGitLabData(
        getApiUrl(
          `/projects/${encodeURIComponent(listItem.target_project_full_path)}/merge_requests/${listItem.iid}`
        )
      );
      if (mergeRequest) {
        const action = {
          waitForOursCount: 0,
          waitForTheirsCount: 0,
          otherUnresolvedCount: 0,
          needUserReview: false
        };
        const isUserAuthor = mergeRequest.author.id === userId;
        const isUserReviewer = mergeRequest.assignees.some((user) => user.id === userId) || mergeRequest.reviewers.some((user) => user.id === userId);
        if (isUserAuthor) {
          renderFallback = false;
          for (const discusstion of discussions) {
            if (discusstion.resolvable && !discusstion.resolved && discusstion.notes.length > 0) {
              if (discusstion.notes.at(-1).author.id === userId) {
                action.waitForTheirsCount += 1;
              } else {
                action.waitForOursCount += 1;
              }
            }
          }
          createThreadActionBadges(element, action);
        } else if (isUserReviewer) {
          renderFallback = false;
          action.needUserReview = true;
          for (const discusstion of discussions) {
            if (discusstion.resolvable && !discusstion.resolved && discusstion.notes.length > 0) {
              if (discusstion.notes.at(0).author.id === userId) {
                action.needUserReview = false;
                if (discusstion.notes.at(-1).author.id === userId) {
                  action.waitForTheirsCount += 1;
                } else {
                  action.waitForOursCount += 1;
                }
              }
            }
            action.otherUnresolvedCount = resolvable - resolved - action.waitForTheirsCount - action.waitForOursCount;
          }
          createThreadActionBadges(element, action);
        }
      }
    }
    if (!renderFallback) {
      return;
    }
    if (resolvable > resolved) {
      createThreadsBadge(element, "danger", resolved, resolvable);
    } else if (resolved === resolvable && resolvable > 0) {
      createThreadsBadge(element, "success", resolved, resolvable);
    }
  }
  async function addMergeRequestDiffMeta(element, mergeRequestUrl) {
    const diffsMeta = await fetchGitLabData(
      `${mergeRequestUrl}/diffs_metadata.json`
    );
    if (!diffsMeta) {
      return;
    }
    const { addedLineCount, deleteLinCount, fileCount } = dehydrateDiff(diffsMeta);
    createDiffStat(element, fileCount, addedLineCount, deleteLinCount);
  }
  function dehydrateDiff(diffsMeta) {
    const excludeRegexps = [
      /\.po$/,
      // translation files
      /mocks/,
      // mocks
      /(spec|test)\.\w+$/,
      // tests
      /package-lock.json/
      // auto generated files
    ];
    let addedLineCount = 0;
    let deleteLinCount = 0;
    let fileCount = 0;
    file_loop: for (const file of diffsMeta.diff_files) {
      for (const excludeRegexp of excludeRegexps) {
        if (excludeRegexp.test(file.new_path)) {
          continue file_loop;
        }
      }
      addedLineCount += file.added_lines;
      deleteLinCount += file.removed_lines;
      fileCount += 1;
    }
    return {
      addedLineCount,
      deleteLinCount,
      fileCount
    };
  }
  async function enhanceMergeRequestList() {
    var _a;
    const mergeRequests = document.querySelectorAll(".merge-request");
    ensurePanelLayout();
    for (const mergeRequest of mergeRequests) {
      const mergeRequestUrl = (_a = mergeRequest.querySelector(
        ".merge-request-title-text a"
      )) == null ? void 0 : _a.href;
      if (!mergeRequestUrl) {
        continue;
      }
      const metaList = $(mergeRequest).find(".issuable-meta ul, ul.controls")[0];
      addMergeRequestThreadMeta(metaList, mergeRequestUrl);
      addMergeRequestDiffMeta(metaList, mergeRequestUrl);
      $(mergeRequest).on("click", () => {
        ensureSidePanel("MR Panel", mergeRequestUrl);
      });
    }
  }
  async function enhanceIssueDetailPage() {
    const title = $("#related-merge-requests")[0];
    if (!title) {
      return;
    }
    ensurePanelLayout();
    waitForKeyElements(
      ".issue-details.issuable-details.js-issue-details div.js-issue-widgets .related-items-list li:not(.js-related-issues-token-list-item)",
      (mergeRequest) => {
        (async () => {
          var _a;
          console.debug(
            "inserting merge request meta to related merge requests",
            mergeRequest
          );
          const statusSvg = mergeRequest.querySelector(".item-title svg");
          if (!statusSvg) {
            return;
          }
          const mergeRequestStatus = statusSvg.getAttribute("aria-label");
          const mergeRequestUrl = (_a = mergeRequest.querySelector(".item-title a")) == null ? void 0 : _a.href;
          if (!mergeRequestUrl) {
            return;
          }
          $(mergeRequest).on("click", () => {
            ensureSidePanel("MR Panel", mergeRequestUrl);
          });
          switch (mergeRequestStatus) {
            case "opened": {
              $(mergeRequest).css({
                "background-color": "var(--gl-status-warning-background-color)"
              });
              break;
            }
            case "merged": {
              break;
            }
            case "closed": {
              $(mergeRequest).css({
                "background-color": "#c1c1c14d",
                filter: "grayscale(1)",
                "text-decoration": "line-through"
              });
              return;
            }
          }
          const diffsMeta = await fetchGitLabData(
            `${mergeRequestUrl}/diffs_metadata.json`
          );
          if (!diffsMeta) {
            return;
          }
          const metaDiv = mergeRequest.querySelector(
            ".item-meta .item-attributes-area"
          );
          if (!metaDiv) {
            return;
          }
          if (mergeRequestStatus === "opened") {
            await addMergeRequestThreadMeta(metaDiv, mergeRequestUrl);
            await addMergeRequestDiffMeta(metaDiv, mergeRequestUrl);
          }
          $("<span/>").text(diffsMeta.project_path).prependTo(metaDiv);
        })();
      },
      true
    );
  }
  function enhanceIssueList() {
    ensurePanelLayout();
    waitForKeyElements("ul.issues-list > li", (issue) => {
      var _a;
      const issueUrl = (_a = issue.querySelector("a")) == null ? void 0 : _a.href;
      if (!issueUrl) {
        console.error("cannot find url for issue");
        return;
      }
      $(issue).on("click", () => {
        ensureSidePanel("Issue Panel", issueUrl);
      });
      return true;
    });
  }
  const enhanceIssueCard = async (mutationList) => {
    var _a;
    for (const mutation of mutationList) {
      if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
        for (const node of mutation.addedNodes) {
          if (node instanceof Element && node.matches("li.board-card")) {
            const issueUrl = (_a = node.querySelector(
              "h4.board-card-title > a"
            )) == null ? void 0 : _a.href;
            const infoItems = node.querySelector(
              "span.board-info-items"
            );
            if (!issueUrl || !infoItems) {
              continue;
            }
            const issue = await fetchGitLabData(`${issueUrl}.json`);
            if (!issue) {
              continue;
            }
            const relatedMergeRequest = await fetchGitLabData(
              getApiUrl(
                `/projects/${issue.project_id}/issues/${issue.iid}/related_merge_requests`
              )
            ) ?? [];
            const total = relatedMergeRequest.length;
            const opened = relatedMergeRequest.filter(
              (mergeRequest) => mergeRequest.state === "opened"
            ).length;
            createIssueCardMergeRequestInfo(infoItems, opened, total);
            $("<button/>", { class: "btn btn-default btn-sm gl-button" }).css({
              "font-family": "SauceCodePro Mono"
            }).text("").on("click", (e) => {
              e.stopPropagation();
              openModal(issueUrl);
            }).appendTo(infoItems);
          }
        }
      } else if (mutation.type === "attributes") ;
    }
    return;
  };
  const observer = new MutationObserver(enhanceIssueCard);
  const enhanceIssueBoard = () => {
    observer.disconnect();
    const boardElement = document.querySelector(".boards-list");
    if (!boardElement) {
      return;
    }
    observer.observe(boardElement, {
      attributes: false,
      childList: true,
      subtree: true
    });
  };
  const issueDetailRegex = /\/issues\/\d+/;
  const mergeRequestListRegex = /\/merge_requests(?!\/\d+)/;
  const issueListRegex = /\/issues(?!\/\d+)/;
  const epicListRegex = /\/epics(?!\/\d+)/;
  const issueBoardRegex = /\/boards\/\d+/;
  const enhance = () => {
    if (mergeRequestListRegex.test(window.location.href)) {
      enhanceMergeRequestList();
    }
    if (issueDetailRegex.test(window.location.href)) {
      enhanceIssueDetailPage();
    }
    if (issueListRegex.test(window.location.href)) {
      enhanceIssueList();
    }
    if (epicListRegex.test(window.location.href)) {
      enhanceIssueList();
    }
    if (issueBoardRegex.test(window.location.href)) {
      enhanceIssueBoard();
    }
  };
  window.onload = enhance;
  if (window.onurlchange === null) {
    window.addEventListener("urlchange", enhance);
  }

})($);