Last active
March 2, 2026 17:19
-
-
Save n0kovo/90a724192b3fa82cf867b6cb7c44e4ce to your computer and use it in GitHub Desktop.
GitHub PR Request Review From Specific User
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment