GitHub PR Request Review From Specific User

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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