Last active
April 4, 2026 09:15
-
-
Save PaulvdDool/c34a2cbfaeb9293a49fb7fc97f3a3d47 to your computer and use it in GitHub Desktop.
Github - Add reviewer avatars and review status on PR dashboard
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 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