GitHub PR Request Review From Specific User

Add a searchable input to request a GitHub pull request review from any collaborator

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         GitHub PR Request Review From Specific User
// @namespace    gh-request-review
// @author       n0kovo
// @version      1.1
// @description  Add a searchable input to request a GitHub pull request review from any collaborator
// @match        https://github.com/*/pull/*
// @grant        GM.getValue
// @grant        GM.setValue
// @icon         https://github.com/favicon.ico
// @license      GPL-3.0
// @compatible   chrome
// @compatible   firefox
// @compatible   edge
// @tag          github
// @tag          pull-request
// @tag          pr-review
// @tag          undocumented-api
// @keyword      github pull-request pr-review undocumented-api
// @homepageURL  https://greasyfork.org/en/scripts/568121/code

// ==/UserScript==

(function () {
  'use strict';

  const pathMatch = location.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
  if (!pathMatch) return;
  const [, owner, repo] = pathMatch;

  // ── Recent reviewers (persisted via GM storage) ──

  const STORAGE_KEY = 'recentReviewers';
  const MAX_RECENT = 5;

  async function getRecentReviewers() {
    const raw = await GM.getValue(STORAGE_KEY, '[]');
    try {
      return JSON.parse(raw);
    } catch {
      return [];
    }
  }

  async function saveRecentReviewer(user) {
    const login = user.type === 'user' ? user.login : user.name;
    const entry = { id: user.id, login, name: user.name || '' };
    const list = await getRecentReviewers();
    const filtered = list.filter((r) => r.id !== entry.id);
    filtered.unshift(entry);
    await GM.setValue(STORAGE_KEY, JSON.stringify(filtered.slice(0, MAX_RECENT)));
  }

  // ── GitHub helpers ──

  function getPrDatabaseId() {
    const el = document.querySelector(
      '.js-discussion-sidebar-item[data-channel][data-channel-event-name="reviewers_updated"]'
    );
    if (!el) return null;
    try {
      const raw = el.getAttribute('data-channel').split('--')[0];
      const json = JSON.parse(atob(raw));
      return json.c.split(':')[1];
    } catch {
      return null;
    }
  }

  function getNonce() {
    const meta = document.querySelector('meta[name="fetch-nonce"]');
    return meta ? meta.content : '';
  }

  function getFormTokens() {
    const form = document.querySelector(
      'form.js-issue-sidebar-form[action*="review-requests"]'
    );
    if (!form) return null;
    const token = form.querySelector('input[name="authenticity_token"]');
    const partial = form.querySelector('input[name="partial_last_updated"]');
    return {
      authenticity_token: token ? token.value : '',
      partial_last_updated: partial ? partial.value : '',
      action: form.getAttribute('action'),
    };
  }

  let cachedSuggestions = null;

  async function fetchSuggestions() {
    if (cachedSuggestions) return cachedSuggestions;
    const prId = getPrDatabaseId();
    if (!prId) return [];
    const url = `/suggestions/pull_request/${prId}?mention_suggester=1&repository=${encodeURIComponent(repo)}&user_id=${encodeURIComponent(owner)}`;
    try {
      const resp = await fetch(url, {
        headers: {
          accept: 'application/json',
          'x-requested-with': 'XMLHttpRequest',
          'x-fetch-nonce': getNonce(),
        },
      });
      if (!resp.ok) return [];
      const data = await resp.json();
      cachedSuggestions = data.slice(1);
      return cachedSuggestions;
    } catch {
      return [];
    }
  }

  async function requestReview(userId) {
    const tokens = getFormTokens();
    if (!tokens) {
      alert('Review request form not found. Do you have permission to request reviews on this PR?');
      return false;
    }
    const body = new FormData();
    body.append('re_request_reviewer_id', String(userId));
    body.append('authenticity_token', tokens.authenticity_token);
    body.append('partial_last_updated', tokens.partial_last_updated);
    try {
      const resp = await fetch(tokens.action, {
        method: 'POST',
        headers: {
          'x-requested-with': 'XMLHttpRequest',
          accept: 'text/html',
          'x-fetch-nonce': getNonce(),
        },
        body,
      });
      if (resp.ok) {
        const html = await resp.text();
        const sidebar = document.querySelector(
          '.js-discussion-sidebar-item[data-channel-event-name="reviewers_updated"]'
        );
        if (sidebar) {
          sidebar.outerHTML = html;
          // buildUI will re-run via the MutationObserver
        }
        return true;
      }
      alert('Failed to request review (HTTP ' + resp.status + ')');
      return false;
    } catch (err) {
      alert('Failed to request review: ' + err.message);
      return false;
    }
  }

  // ── UI ──

  function showConfirm(container, user, onConfirm) {
    // Remove any existing confirmation bar
    const existing = container.querySelector('.grr-confirm');
    if (existing) existing.remove();

    const login = user.login || user.name;

    const bar = document.createElement('div');
    bar.className = 'grr-confirm';
    Object.assign(bar.style, {
      display: 'flex',
      alignItems: 'center',
      gap: '6px',
      marginTop: '6px',
      fontSize: '12px',
    });

    const avatar = document.createElement('img');
    avatar.src = `https://avatars.githubusercontent.com/u/${user.id}?s=40&v=4`;
    avatar.width = 16;
    avatar.height = 16;
    avatar.style.borderRadius = '50%';

    const label = document.createElement('span');
    label.textContent = login;
    label.style.fontWeight = '600';
    label.style.flex = '1';
    label.style.overflow = 'hidden';
    label.style.textOverflow = 'ellipsis';
    label.style.whiteSpace = 'nowrap';

    const btnConfirm = document.createElement('button');
    btnConfirm.type = 'button';
    btnConfirm.textContent = 'Request';
    btnConfirm.className = 'btn btn-sm btn-primary';
    btnConfirm.addEventListener('click', () => {
      btnConfirm.disabled = true;
      btnCancel.disabled = true;
      saveRecentReviewer(user).then(() => onConfirm());
    });

    const btnCancel = document.createElement('button');
    btnCancel.type = 'button';
    btnCancel.textContent = 'Cancel';
    btnCancel.className = 'btn btn-sm';
    btnCancel.addEventListener('click', () => bar.remove());

    bar.appendChild(avatar);
    bar.appendChild(label);
    bar.appendChild(btnConfirm);
    bar.appendChild(btnCancel);
    container.appendChild(bar);
  }

  function buildUI() {
    const sidebar = document.querySelector(
      '.js-discussion-sidebar-item[data-channel-event-name="reviewers_updated"]'
    );
    if (!sidebar) return;
    const form = sidebar.querySelector('form.js-issue-sidebar-form');
    if (!form) return;
    if (form.querySelector('.grr-toggle')) return;

    // Toggle link row — same class as the "Still in progress?" element
    const toggleRow = document.createElement('div');
    toggleRow.className = 'py-2 grr-toggle';

    const toggleLink = document.createElement('a');
    toggleLink.href = '#';
    toggleLink.className = 'btn-link Link--muted Link--inTextBlock';
    toggleLink.textContent = 'Request review from someone';
    toggleRow.appendChild(toggleLink);

    // Recent reviewers list
    const recentList = document.createElement('div');
    recentList.className = 'grr-recent';
    Object.assign(recentList.style, { display: 'none' });

    getRecentReviewers().then((recents) => {
      if (recents.length === 0) return;
      recents.forEach((r) => {
        const chip = document.createElement('div');
        Object.assign(chip.style, {
          display: 'flex',
          alignItems: 'center',
          gap: '6px',
          padding: '3px 0',
          fontSize: '12px',
          cursor: 'pointer',
        });
        chip.addEventListener('mouseenter', () => {
          chip.style.opacity = '0.7';
        });
        chip.addEventListener('mouseleave', () => {
          chip.style.opacity = '';
        });

        const avatar = document.createElement('img');
        avatar.src = `https://avatars.githubusercontent.com/u/${r.id}?s=40&v=4`;
        avatar.width = 16;
        avatar.height = 16;
        avatar.style.borderRadius = '50%';

        const name = document.createElement('span');
        name.textContent = r.login;

        chip.appendChild(avatar);
        chip.appendChild(name);

        chip.addEventListener('click', () => {
          container.style.display = 'none';
          showConfirm(toggleRow, r, () => requestReview(r.id));
        });

        recentList.appendChild(chip);
      });
    });

    // Search container, hidden by default
    const container = document.createElement('div');
    container.className = 'grr-container';
    Object.assign(container.style, {
      position: 'relative',
      display: 'none',
      marginBottom: '8px',
    });

    const input = document.createElement('input');
    input.type = 'text';
    input.placeholder = 'Search users\u2026';
    input.className = 'form-control input-sm';
    input.style.width = '100%';

    const dropdown = document.createElement('div');
    Object.assign(dropdown.style, {
      position: 'absolute',
      zIndex: '100',
      background: 'var(--bgColor-default, #fff)',
      border: '1px solid var(--borderColor-muted, #d0d7de)',
      borderRadius: '6px',
      maxHeight: '200px',
      overflowY: 'auto',
      width: '100%',
      display: 'none',
      boxShadow: '0 8px 24px rgba(140,149,159,0.2)',
      marginTop: '2px',
    });

    toggleLink.addEventListener('click', (e) => {
      e.preventDefault();
      const open = container.style.display !== 'none';
      container.style.display = open ? 'none' : '';
      recentList.style.display = open ? 'none' : '';
      if (!open) input.focus();
    });

    function renderDropdown(query) {
      fetchSuggestions().then((suggestions) => {
        dropdown.textContent = '';
        const q = (query || '').toLowerCase();
        const filtered = suggestions.filter((s) => {
          if (s.type === 'user') {
            return (
              s.login.toLowerCase().includes(q) ||
              (s.name && s.name.toLowerCase().includes(q))
            );
          }
          return s.name.toLowerCase().includes(q);
        });
        if (filtered.length === 0) {
          dropdown.style.display = 'none';
          return;
        }
        filtered.slice(0, 5).forEach((s) => {
          const row = document.createElement('div');
          Object.assign(row.style, {
            padding: '6px 10px',
            cursor: 'pointer',
            display: 'flex',
            alignItems: 'center',
            gap: '8px',
            fontSize: '12px',
          });
          row.addEventListener('mouseenter', () => {
            row.style.background = 'var(--bgColor-muted, #f6f8fa)';
          });
          row.addEventListener('mouseleave', () => {
            row.style.background = '';
          });

          const avatar = document.createElement('img');
          avatar.src = `https://avatars.githubusercontent.com/u/${s.id}?s=40&v=4`;
          avatar.width = 20;
          avatar.height = 20;
          avatar.style.borderRadius = '50%';

          const login = s.type === 'user' ? s.login : s.name;
          const extra = s.type === 'user' ? s.name : s.description;

          const loginSpan = document.createElement('strong');
          loginSpan.textContent = login;

          const extraSpan = document.createElement('span');
          extraSpan.textContent = extra || '';
          extraSpan.style.color = 'var(--fgColor-muted, #656d76)';

          row.appendChild(avatar);
          row.appendChild(loginSpan);
          if (extra) row.appendChild(extraSpan);

          row.addEventListener('click', () => {
            dropdown.style.display = 'none';
            input.value = '';
            container.style.display = 'none';
            recentList.style.display = 'none';
            showConfirm(toggleRow, s, () => {
              saveRecentReviewer(s).then(() => requestReview(s.id));
            });
          });

          dropdown.appendChild(row);
        });
        dropdown.style.display = '';
      });
    }

    let debounce;
    input.addEventListener('focus', () => renderDropdown(input.value));
    input.addEventListener('input', () => {
      clearTimeout(debounce);
      debounce = setTimeout(() => renderDropdown(input.value), 100);
    });
    document.addEventListener('click', (e) => {
      if (!container.contains(e.target)) dropdown.style.display = 'none';
    });

    container.appendChild(input);
    container.appendChild(dropdown);
    form.appendChild(toggleRow);
    form.appendChild(recentList);
    form.appendChild(container);
  }

  // Re-run buildUI whenever the sidebar updates (GitHub replaces partials via sockets).
  new MutationObserver(buildUI).observe(document.body, {
    childList: true,
    subtree: true,
  });

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', buildUI);
  } else {
    buildUI();
  }
})();