GitHub PR Request Review From Specific User

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

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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