Skip to content

Instantly share code, notes, and snippets.

@n0kovo
Last active March 2, 2026 17:19
Show Gist options
  • Select an option

  • Save n0kovo/90a724192b3fa82cf867b6cb7c44e4ce to your computer and use it in GitHub Desktop.

Select an option

Save n0kovo/90a724192b3fa82cf867b6cb7c44e4ce to your computer and use it in GitHub Desktop.
GitHub PR Request Review From Specific User
// ==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