GitHub PR Request Review From Specific User

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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