// ==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);
}
})($);