GitLab Review Tags

Insert predefined review tags with emoji and tooltips (shown below) in GitLab MRs

// ==UserScript==
// @name         GitLab Review Tags
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Insert predefined review tags with emoji and tooltips (shown below) in GitLab MRs
// @exclude      https://gitlab.com/*/merge_requests/new*
// @match        https://gitlab.com/*/merge_requests/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=gitlab.com
// @grant        none
// @license      MIT
// ==/UserScript==


(function () {
  'use strict';

  const TAGS = [
    { label: '[question]', emoji: '❓', emojiCode: ':question:', title: 'I’m asking for clarification, not blocking approval.' },
    { label: '[nitpick]', emoji: '🔍', emojiCode: ':mag:', title: 'Very minor; doesn’t block, can fix later or skip.' },
    { label: '[info]', emoji: 'ℹ️', emojiCode: ':information_source:', title: 'Providing context or background, no action needed.' },
    { label: '[suggestion]', emoji: '💡', emojiCode: ':bulb:', title: 'Optional improvement idea, not a requirement.' },
    { label: '[blocking]', emoji: '⛔', emojiCode: ':no_entry:', title: 'This must be addressed before approval.' },
    { label: '[trust]', emoji: '🤝', emojiCode: ':handshake:', title: 'I didn’t check this part deeply but I trust your expertise.' },
    { label: '[follow-up]', emoji: '📌', emojiCode: ':pushpin:', title: 'Let’s merge, but create a ticket to improve or clean up later.' },
    { label: '[praise]', emoji: '🙌', emojiCode: ':raised_hands:', title: 'Explicitly calling out something well done or elegant.' },
    { label: '[discussion]', emoji: '🗣️', emojiCode: ':speaking_head:', title: 'Not blocking, but we should talk as a team about this approach or pattern.' }
  ];

  const STYLE = `
    :root {
      --tooltip-bg: #fafafa;
      --tooltip-color: #303030;
      --tag-bg: #fafafa;
      --tag-border: #bfbfbf;
      --button-border: #bfbfbf;
      --button-bg: #fafafa;
      --button-hover-bg: #dbdbdb;
      --button-hover-border: #428fdc;
    }

    body.gl-dark {
      --tooltip-bg: var(--gray-10);
      --tooltip-color: var(--gray-950);
      --tag-bg: var(--gray-10);
      --tag-border: var(--gray-100);
      --button-border: var(--gray-100);
      --button-bg: #1f1f1f;
      --button-hover-bg: #303030;
      --button-hover-border: #428fdc;
    }

    .gl-tag-inserter {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
      gap: 6px;
      margin: 6px 0;
      padding: 6px;
      background-color: var(--tag-bg);
      border: 1px solid var(--tag-border);
      border-radius: 8px;
      font-family: system-ui, sans-serif;
      animation: fadeIn 0.3s ease-out;
    }

    .gl-tag-inserter button {
      background-color: var(--button-bg);
      border: 1px solid var(--button-border);
      border-radius: 20px;
      padding: 6px 10px;
      font-size: 13px;
      cursor: pointer;
      transition: all 0.2s ease;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 6px;
      white-space: nowrap;
      position: relative;
    }

    .gl-tag-inserter button:hover {
      background-color: var(--button-hover-bg);
      border: 1px solid var(--button-hover-border);
      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
    }

    .gl-tag-inserter button::after {
      content: attr(data-tooltip);
      position: absolute;
      top: 30px;
      left: 50%;
      transform: translateX(-50%);
      opacity: 0;
      background-color: var(--tooltip-bg);
      color: var(--tooltip-color);
      padding: 6px 8px;
      border-radius: 6px;
      font-size: 12px;
      white-space: pre-wrap;
      pointer-events: none;
      transition: opacity 0.2s ease, transform 0.2s ease;
      z-index: 9999;
      width: 120px;
      box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
    }

    .gl-tag-inserter button:hover::after {
      opacity: 1;
      transform: translateX(-50%) translateY(4px);
    }

    @keyframes fadeIn {
      from { opacity: 0; transform: translateY(-4px); }
      to { opacity: 1; transform: translateY(0); }
    }
  `;

  function injectStyle() {
    if (document.getElementById('gl-tag-style')) return;
    const style = document.createElement('style');
    style.id = 'gl-tag-style';
    style.innerText = STYLE;
    document.head.appendChild(style);
  }

  function createTagButton(tag, textarea) {
    const btn = document.createElement('button');
    btn.innerHTML = `${tag.emoji} ${tag.label}`;
    btn.setAttribute('data-tooltip', tag.title);
    btn.addEventListener('click', (e) => {
      e.preventDefault();
      const prefix = `${tag.emojiCode} ${tag.label} `;
      if (!textarea.value.startsWith(prefix)) {
        const newValue = prefix + textarea.value;
        textarea.value = newValue;

        textarea.dispatchEvent(new Event('input', { bubbles: true }));
        textarea.dispatchEvent(new Event('change', { bubbles: true }));

        textarea.focus();
      }
    });
    return btn;
  }

  function injectButtonsAboveTextarea(textarea) {
    if (textarea.dataset.glTagInjected) return;

    const wrapper = document.createElement('div');
    wrapper.className = 'gl-tag-inserter';
    TAGS.forEach(tag => wrapper.appendChild(createTagButton(tag, textarea)));

    textarea.parentNode.insertBefore(wrapper, textarea);
    textarea.dataset.glTagInjected = 'true';
  }

  function scanAndInject() {
    document.querySelectorAll('textarea').forEach(injectButtonsAboveTextarea);
  }

  injectStyle();

  const observer = new MutationObserver(() => {
    scanAndInject();
  });

  observer.observe(document.body, { childList: true, subtree: true });

  scanAndInject();
})();