GitHub PR Request Review From Specific User

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

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

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