Skip to content

Instantly share code, notes, and snippets.

@PaulvdDool
Last active April 4, 2026 09:15
Show Gist options
  • Select an option

  • Save PaulvdDool/c34a2cbfaeb9293a49fb7fc97f3a3d47 to your computer and use it in GitHub Desktop.

Select an option

Save PaulvdDool/c34a2cbfaeb9293a49fb7fc97f3a3d47 to your computer and use it in GitHub Desktop.
Github - Add reviewer avatars and review status on PR dashboard
// ==UserScript==
// @name Enhanced Pull Request Overview
// @namespace http://tampermonkey.net/
// @version 2026-04-04
// @description GitHub should have added this years ago
// @author mxt-mischa, PaulvdDool
// @match https://github.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant GM_addStyle
// @license MIT
// @downloadURL https://update.greasyfork.org/scripts/530450/Enhanced%20Pull%20Request%20Overview.user.js
// @updateURL https://update.greasyfork.org/scripts/530450/Enhanced%20Pull%20Request%20Overview.meta.js
// ==/UserScript==
GM_addStyle(`
.pr-branch-row {
padding: 2px 8px 4px 16px;
font-size: 12px;
color: var(--fgColor-muted, #6e7781);
}
.pr-branch-badge {
display: inline-block;
background-color: var(--bgColor-accent-muted, #ddf4ff);
color: var(--fgColor-accent, #0969da);
border-radius: 6px;
padding: 1px 6px;
font-size: 11px;
font-family: monospace;
}
.pr-reviewer-chips {
display: inline-flex;
align-items: center;
gap: 3px;
vertical-align: middle;
margin-left: 4px;
}
.reviewer-chip {
position: relative;
display: inline-flex;
vertical-align: middle;
}
.reviewer-chip img {
border-radius: 50%;
width: 20px;
height: 20px;
display: block;
}
.reviewer-status-badge {
position: absolute;
top: -3px;
right: -3px;
width: 10px;
height: 10px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid var(--bgColor-default, #ffffff);
}
.reviewer-status-badge.approved {
background-color: var(--fgColor-success, #1a7f37);
color: white;
}
.reviewer-status-badge.denied {
background-color: var(--fgColor-danger, #d1242f);
color: white;
}
.reviewer-status-badge.pending {
background-color: var(--fgColor-attention, #9a6700);
color: white;
}
.reviewer-status-badge.comment {
background-color: var(--fgColor-muted, #6e7781);
color: white;
}
.reviewer-status-badge svg {
width: 8px;
height: 8px;
fill: currentColor;
}
`);
// SVG icons for status badges
const STATUS_ICONS = {
approved: `<svg viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>`,
denied: `<svg viewBox="0 0 16 16"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>`,
pending: `<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="4"/></svg>`,
comment: `<svg viewBox="0 0 16 16"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>`,
};
// Bot accounts to skip from reviewer display
const SKIP_USERNAMES = new Set(['copilot-pull-request-reviewer', 'dependabot[bot]', 'dependabot']);
/**
* Parse reviewer info from a PR page document.
* Returns array of { username, avatarUrl, approved, denied, pending, comment }
*/
function getReviews(doc) {
// Find the Reviewers sidebar specifically (not Assignees — both use .sidebar-assignee)
const sidebarItems = doc.querySelectorAll('.discussion-sidebar-item.sidebar-assignee');
let reviewerSidebar = null;
for (const item of sidebarItems) {
if (item.querySelector('form[aria-label="Select reviewers"]')) {
reviewerSidebar = item;
break;
}
}
if (!reviewerSidebar) {
console.debug('[EPR] Reviewer sidebar not found');
return [];
}
const paragraphs = Array.from(reviewerSidebar.querySelectorAll('p'));
return paragraphs.map(getReviewFromElement).filter(Boolean);
}
function getReviewFromElement(element) {
const userElement = element.querySelector('span[data-hovercard-type="user"]');
if (!userElement) return null;
const username = userElement.getAttribute('data-assignee-name');
if (!username || SKIP_USERNAMES.has(username)) return null;
// Use the avatar img already present in the sidebar (real GitHub CDN URL)
const avatarImg = element.querySelector('img.avatar');
const avatarUrl = avatarImg
? avatarImg.src.replace(/\?.*$/, '?size=28')
: `https://avatars.githubusercontent.com/${username}?size=28`;
// Detect review state from SVG icon classes within this paragraph
// Approved: octicon-check + color-fg-success
// Changes requested: octicon-file-diff + color-fg-danger
// Commented: octicon-comment + color-fg-muted
// Pending/awaiting: octicon-dot-fill + hx_dot-fill-pending-icon
const approved = !!element.querySelector('svg.octicon-check.color-fg-success');
const denied = !!element.querySelector('svg.octicon-file-diff.color-fg-danger');
const comment = !!element.querySelector('svg.octicon-comment.color-fg-muted');
const pending = !!element.querySelector('svg.hx_dot-fill-pending-icon');
return { username, avatarUrl, approved, denied, pending, comment };
}
async function fetchPullRequestDOM(pullRequestUrl) {
try {
const res = await fetch(pullRequestUrl);
const htmlString = await res.text();
const parser = new DOMParser();
return parser.parseFromString(htmlString, 'text/html');
} catch (error) {
console.error('[EPR] Failed to fetch PR content:', error);
return null;
}
}
function getTargetBranch(doc) {
const text = doc.body?.innerText || '';
const match = text.match(/wants to merge.*?into\s+(\S+)\s+from/i);
if (match?.[1]) return match[1];
const el = doc.querySelector('.commit-ref.base-ref a span.css-truncate-target')
|| doc.querySelector('.base-ref span:last-child');
return el ? el.textContent.trim() : '';
}
function getSourceBranch(doc) {
const text = doc.body?.innerText || '';
const match = text.match(/wants to merge.*?from\s+(\S+)\s*(?:\n|$)/i);
if (match?.[1]) return match[1];
const el = doc.querySelector('.commit-ref.head-ref a span.css-truncate-target')
|| doc.querySelector('.head-ref span:last-child');
return el ? el.textContent.trim() : '';
}
function buildBranchRow(targetBranch, sourceBranch) {
if (!targetBranch && !sourceBranch) return null;
const row = document.createElement('div');
row.className = 'pr-branch-row';
const targetSpan = document.createElement('span');
targetSpan.className = 'pr-branch-badge';
targetSpan.textContent = targetBranch || '?';
const arrow = document.createElement('span');
arrow.textContent = ' ← ';
arrow.style.margin = '0 2px';
const sourceSpan = document.createElement('span');
sourceSpan.className = 'pr-branch-badge';
sourceSpan.textContent = sourceBranch || '?';
row.appendChild(targetSpan);
row.appendChild(arrow);
row.appendChild(sourceSpan);
return row;
}
function buildReviewerChips(reviews) {
// Returns an inline-flex SPAN to be appended inside the Title container
// (as a sibling of trailingBadgesContainer, NOT inside it — that has overflow:hidden)
const wrapper = document.createElement('span');
wrapper.className = 'pr-reviewer-chips';
reviews.forEach(review => {
const chip = document.createElement('span');
chip.className = 'reviewer-chip';
chip.title = review.username;
const img = document.createElement('img');
img.src = review.avatarUrl;
img.width = 20;
img.height = 20;
img.loading = 'lazy';
img.alt = review.username;
let statusClass = '';
let iconHtml = '';
if (review.approved) { statusClass = 'approved'; iconHtml = STATUS_ICONS.approved; }
else if (review.denied) { statusClass = 'denied'; iconHtml = STATUS_ICONS.denied; }
else if (review.pending) { statusClass = 'pending'; iconHtml = STATUS_ICONS.pending; }
else if (review.comment) { statusClass = 'comment'; iconHtml = STATUS_ICONS.comment; }
chip.appendChild(img);
if (statusClass) {
const badge = document.createElement('span');
badge.className = `reviewer-status-badge ${statusClass}`;
badge.innerHTML = iconHtml;
chip.appendChild(badge);
}
wrapper.appendChild(chip);
});
return wrapper;
}
/**
* Find the PR list container — supports both new ListView UI and classic UI.
*/
function findPrContainer() {
// New GitHub UI (2024+): UL with data-listview-component
const listView = document.querySelector('ul[data-listview-component]');
if (listView) return listView;
// Classic GitHub UI fallback
const toolbar = document.querySelector('div[id="js-issues-toolbar"]');
if (toolbar) {
return toolbar.parentElement?.querySelector('div.js-navigation-container') || null;
}
return null;
}
function getPrItemsFromContainer(container) {
if (!container) return [];
const tag = container.tagName?.toLowerCase();
return Array.from(container.querySelectorAll(tag === 'ul' ? ':scope > li' : ':scope > div'));
}
function getPrLinkFromItem(item) {
return item.querySelector('[data-testid="listitem-title-link"]')
|| item.querySelector('.Link--primary');
}
async function enrichPullRequests() {
console.debug('[EPR] Starting enrichment...');
const container = findPrContainer();
if (!container) {
console.debug('[EPR] PR container not found');
return;
}
const prItems = getPrItemsFromContainer(container);
console.info(`[EPR] Found ${prItems.length} PR items`);
if (prItems.length === 0) return;
await Promise.allSettled(prItems.map(async (item) => {
try {
if (item.getAttribute('data-epr-processed') === 'true') return;
item.setAttribute('data-epr-processed', 'true');
const linkElement = getPrLinkFromItem(item);
if (!linkElement) {
console.warn('[EPR] No link found in PR item', item);
return;
}
const prUrl = linkElement.href;
console.debug('[EPR] Processing PR:', prUrl);
const doc = await fetchPullRequestDOM(prUrl);
if (!doc) return;
const reviews = getReviews(doc);
const targetBranch = getTargetBranch(doc);
const sourceBranch = getSourceBranch(doc);
// Inject branch row below the list item
const branchRow = buildBranchRow(targetBranch, sourceBranch);
if (branchRow) item.appendChild(branchRow);
// Inject reviewer chips into the Title container as a sibling of the
// trailingBadgesContainer — NOT inside it, as that element has overflow:hidden
// which clips anything taller than ~17px.
if (reviews.length > 0) {
const titleContainer = item.querySelector('[data-listview-item-title-container]')
|| item.querySelector('[class*="Title-module__container"]');
if (titleContainer) {
titleContainer.appendChild(buildReviewerChips(reviews));
}
}
} catch (err) {
console.error('[EPR] Error processing PR:', err);
}
}));
}
function isPrListPage() {
return location.pathname.includes('pulls');
}
let enrichTimer = null;
function scheduleEnrichment() {
if (!isPrListPage()) return;
if (enrichTimer) clearTimeout(enrichTimer);
enrichTimer = setTimeout(() => {
enrichTimer = null;
enrichPullRequests();
}, 300);
}
// Watch for GitHub SPA navigation and dynamic list updates
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
scheduleEnrichment();
return;
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Slower interval as a safety net for full page swaps
setInterval(scheduleEnrichment, 5000);
console.info('[EPR] Enhanced Pull Request Overview loaded');
scheduleEnrichment();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment