// ==UserScript== // @name GitHub PR Approved Viewer // @namespace http://tampermonkey.net/ // @version 1.8.0 // @description Adds a 'Code owner review' panel to GitHub Pull Request pages. Displays CODEOWNERS approval status per file path with real-time updates when reviewer approvals change. // @author @SimplyRin // @match https://github.com/* // @icon https://github.githubassets.com/favicons/favicon.svg // @grant none // @updateURL https://raw.githubusercontent.com/SimplyRin/github-tampermonkey/main/src/github-pr-approved-viewer.user.js // @downloadURL https://raw.githubusercontent.com/SimplyRin/github-tampermonkey/main/src/github-pr-approved-viewer.user.js // ==/UserScript== // MIT License // Copyright (c) 2026 SimplyRin // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. (function () { 'use strict'; function isPRPage() { const path = window.location.pathname; // /owner/repo/pull/number の形式をチェック const match = path.match(/^\/[^/]+\/[^/]+\/pull\/\d+\/?$/); return match !== null; } function getCodeOwnersUrl() { const [, owner, repo] = window.location.pathname.split('/'); // PRのベースブランチをDOMから取得、見つからなければ 'main' にフォールバック const branch = document.querySelector('.base-ref')?.textContent?.trim() || document.querySelector('[data-base-ref]')?.dataset?.baseRef || 'main'; return `https://github.com/${owner}/${repo}/blob/${branch}/.github/CODEOWNERS`; } async function getTeamMembers(owner, team) { try { const baseUrl = `https://github.com/orgs/${owner}/teams/${team}`; let page = 1; const list = []; while (true) { const res = await fetch(`${baseUrl}?page=${page}`); const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const uls = doc.getElementsByClassName( "member-listing table-list table-list-bordered adminable" ); for (let i = 0; i < uls.length; i++) { const lis = uls[i].getElementsByTagName("li"); for (let j = 0; j < lis.length; j++) { const span = lis[j].querySelector('span[itemprop="name"]'); if (span) { const name = span.textContent.trim(); if (!list.includes(name)) list.push(name); } else { const name = lis[j].textContent.trim(); if (!list.includes(name)) list.push(name); } } } const nextBtn = doc.querySelector('a[rel="next"]'); if (!nextBtn) break; page++; } return list; } catch (e) { console.error(e); } } async function fetchDoc(url) { const res = await fetch(url); const html = await res.text(); return new DOMParser().parseFromString(html, 'text/html'); } // // CODEOWNER 取得 // async function getCodeOwners() { try { const res = await fetch(getCodeOwnersUrl()); if (res.status === 404) { return false; } const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const codeowner = doc.querySelector( '#copilot-button-positioner > div.CodeBlob-module__codeBlobInner__tfjuQ > div > div.react-code-lines' )?.textContent; return codeowner || null; } catch (e) { console.error(e); } return null; } async function getFilesChanged() { const doc = await fetchDoc(`${window.location.href}/changes`); const elements = doc.getElementsByClassName('Diff-module__diffHeaderWrapper__UgUyv'); const list = []; for (let i = 0; i < elements.length; i++) { const code = elements[i].querySelector('h3 code'); if (code) { list.push(code.textContent.trim()); console.log(`diff[${i}]: ${code.textContent.trim()}`); } } return list; } function cleanFileName(name) { return name.replace(/[\u200E\u200F\u202A-\u202E]/g, '').trim(); } function findCodeOwners(codeownersText, changedFiles) { const rules = codeownersText .split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#')) .map(line => { const parts = line.split(/\s+/); const pattern = parts[0]; const owners = parts.slice(1); return { pattern, owners }; }); const result = []; for (let file of changedFiles) { file = cleanFileName(file); file = ensureLeadingSlash(file); let matchedOwners = []; let matchedPattern = null; let matchedIndex = -1; for (let i = 0; i < rules.length; i++) { const regex = patternToRegex(ensureLeadingSlash(rules[i].pattern)); if (regex.test(file)) { matchedOwners = rules[i].owners; matchedPattern = rules[i].pattern; matchedIndex = i; } } result.push({ file: file, codeowner: matchedPattern, owners: matchedOwners, ruleIndex: matchedIndex }); } return result; } function groupByPattern(result) { const map = new Map(); for (const row of result) { const key = row.codeowner || '(no match)'; if (!map.has(key)) { map.set(key, { codeowner: row.codeowner, owners: row.owners, files: [], ruleIndex: row.ruleIndex }); } map.get(key).files.push(row.file); } return Array.from(map.values()).sort((a, b) => { if (a.ruleIndex === -1 && b.ruleIndex === -1) return 0; if (a.ruleIndex === -1) return 1; if (b.ruleIndex === -1) return -1; return a.ruleIndex - b.ruleIndex; }); } async function resolveTeamOwners(result) { const teamCache = new Map(); for (const row of result) { for (const owner of row.owners) { const m = owner.match(/^@([^/]+)\/(.+)$/); if (m && !teamCache.has(owner)) { teamCache.set(owner, []); } } } for (const [teamOwner] of teamCache) { const m = teamOwner.match(/^@([^/]+)\/(.+)$/); if (m) { const [, org, team] = m; const members = await getTeamMembers(org, team); teamCache.set(teamOwner, (members || []).map(u => `@${u}`)); } } return result.map(row => { const expanded = []; for (const owner of row.owners) { if (teamCache.has(owner)) { for (const member of teamCache.get(owner)) { if (!expanded.includes(member)) expanded.push(member); } } else { if (!expanded.includes(owner)) expanded.push(owner); } } return { ...row, owners: expanded }; }); } function ensureLeadingSlash(path) { if (!path.startsWith('/')) { return '/' + path; } return path; } function patternToRegex(pattern) { const normalized = pattern.replace(/^\/+/, '/'); let regex = normalized .replace(/\*\*/g, '\x00GLOBSTAR\x00') .replace(/\./g, '\\.') .replace(/\*/g, '[^/]*') .replace(/\x00GLOBSTAR\x00/g, '.*'); if (normalized === '/*' || normalized === '/') { // ルートワイルドカード: 全ファイルにマッチ regex = '^/.*$'; } else if (normalized.endsWith('/')) { // ディレクトリパターン: 配下の全ファイルにマッチ regex = '^' + regex + '.*$'; } else if (!normalized.includes('/')) { // スラッシュなし: どのディレクトリのファイルにもマッチ regex = '^.*/' + regex.replace(/^\//, '') + '$'; } else { regex = '^' + regex + '$'; } return new RegExp(regex); } function insertNoCodeOwnersSection() { const existingSection = document.querySelector('div[data-codeowner-section="true"]'); if (existingSection) existingSection.remove(); const mergeBox = document.querySelector('div[data-testid="mergebox-partial"]'); if (!mergeBox) return; const wrapper = document.createElement('div'); wrapper.className = 'tmp-ml-md-6 tmp-pl-md-3 tmp-my-3'; wrapper.setAttribute('data-codeowner-section', 'true'); const mergePartialContainer = document.createElement('div'); mergePartialContainer.className = 'MergeBox-module__mergePartialContainer__MTXP9 position-relative'; const borderContainer = document.createElement('div'); borderContainer.className = 'rounded-2'; borderContainer.style.border = '1px solid var(--borderColor-default)'; borderContainer.style.overflow = 'hidden'; const iconWrapper = document.createElement('div'); iconWrapper.className = 'd-none d-lg-block'; iconWrapper.innerHTML = `
`; const container = document.createElement('section'); container.setAttribute('aria-label', 'Code owner approval status'); container.innerHTML = `

Code owner review

No .github/CODEOWNERS file found in this repository.

`; borderContainer.appendChild(container); mergePartialContainer.appendChild(iconWrapper); mergePartialContainer.appendChild(borderContainer); wrapper.appendChild(mergePartialContainer); mergeBox.before(wrapper); } function ensureSkeletonStyles() { if (document.getElementById('codeowner-skeleton-styles')) return; const style = document.createElement('style'); style.id = 'codeowner-skeleton-styles'; style.textContent = ` @keyframes codeowner-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } .codeowner-skel { background-color: var(--bgColor-neutral-muted, rgba(175,184,193,0.2)); border-radius: 6px; animation: codeowner-pulse 1.5s ease-in-out infinite; display: inline-block; } `; document.head.appendChild(style); } function insertSkeletonSection() { ensureSkeletonStyles(); const mergeBox = document.querySelector('div[data-testid="mergebox-partial"]'); if (!mergeBox) return; const existing = document.querySelector('div[data-codeowner-section="true"]'); if (existing) existing.remove(); const wrapper = document.createElement('div'); wrapper.className = 'tmp-ml-md-6 tmp-pl-md-3 tmp-my-3'; wrapper.setAttribute('data-codeowner-section', 'true'); const mergePartialContainer = document.createElement('div'); mergePartialContainer.className = 'MergeBox-module__mergePartialContainer__MTXP9 position-relative'; const borderContainer = document.createElement('div'); borderContainer.className = 'border rounded-2 borderColor-default'; const iconWrapper = document.createElement('div'); iconWrapper.className = 'd-none d-lg-block'; iconWrapper.innerHTML = `
`; const container = document.createElement('section'); container.setAttribute('aria-label', 'Code owner approval status'); container.setAttribute('data-codeowner-loading', 'true'); container.innerHTML = `

`; borderContainer.appendChild(container); mergePartialContainer.appendChild(iconWrapper); mergePartialContainer.appendChild(borderContainer); wrapper.appendChild(mergePartialContainer); mergeBox.before(wrapper); } function insertCodeOwnerSection(result) { // スケルトンまたは既存のセクションを削除 const existingSection = document.querySelector('div[data-codeowner-section="true"]'); if (existingSection) existingSection.remove(); const approvedList = getApprovedList(); const mergeBox = document.querySelector('div[data-testid="mergebox-partial"]'); if (!mergeBox) return; const grouped = groupByPattern(result); const allApproved = grouped.length > 0 && grouped.every(row => { return row.owners && row.owners.length > 0 && row.owners.some(owner => { const username = owner.replace('@', ''); return approvedList[username] === true; }); }); const headerIconBg = allApproved ? 'var(--bgColor-success-emphasis)' : 'var(--bgColor-attention-emphasis)'; const headerIcon = allApproved ? `` : ``; const sectionId = 'codeowner-expandable-' + Date.now(); const wrapper = document.createElement('div'); wrapper.className = 'tmp-ml-md-6 tmp-pl-md-3 tmp-my-3'; wrapper.setAttribute('data-codeowner-section', 'true'); const mergePartialContainer = document.createElement('div'); mergePartialContainer.className = 'MergeBox-module__mergePartialContainer__MTXP9 position-relative'; const borderContainer = document.createElement('div'); borderContainer.className = 'rounded-2'; borderContainer.style.border = allApproved ? '1px solid var(--borderColor-success, #1a7f37)' : '1px solid var(--borderColor-attention, #9a6700)'; borderContainer.style.overflow = 'hidden'; const container = document.createElement("section"); container.setAttribute("aria-label", "Code owner approval status"); container.innerHTML = `
${headerIcon}

Code owner review

${allApproved ? 'All code owners have approved.' : 'Code owner review required.'}

File
Code Owners
Approved by
${(() => { const approvedUsersAll = Object.entries(approvedList).filter(([, v]) => v).map(([u]) => u); return `
${approvedUsersAll.length > 0 ? iconApproved() : iconPending()}
Approved users
-
${approvedUsersAll.length > 0 ? approvedUsersAll.map(u => avatar('@' + u)).join('') : '-' }
`; })()} ${grouped.map((row, rowIdx) => { const rowApproved = row.owners && row.owners.some(owner => { const username = owner.replace('@', ''); return approvedList[username] === true; }); const approvedOwners = (row.owners || []).filter(owner => { const username = owner.replace('@', ''); return approvedList[username] === true; }); const fileCount = row.files.length; const fileLabel = fileCount === 1 ? '1 file' : `${fileCount} files`; return `
${rowApproved ? iconApproved() : iconPending()}
${row.codeowner || '(no match)'}
${fileLabel}
${ownersToggleHtml(row.owners, sectionId + '-row-' + rowIdx)}
${approvedOwners.length > 0 ? approvedOwners.map(owner => avatar(owner)).join('') : '-' }
`; }).join('')}
`; const iconWrapper = document.createElement('div'); iconWrapper.className = 'd-none d-lg-block'; iconWrapper.innerHTML = `
`; borderContainer.appendChild(container); mergePartialContainer.appendChild(iconWrapper); mergePartialContainer.appendChild(borderContainer); wrapper.appendChild(mergePartialContainer); mergeBox.before(wrapper); const toggleBtn = container.querySelector('.MergeBoxSectionHeader-module__button__R1r_x'); const expandable = container.querySelector(`#${sectionId}`); const chevron = container.querySelector('.fgColor-muted.pr-2.pt-2 > div'); toggleBtn.addEventListener('click', () => { const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true'; if (isExpanded) { toggleBtn.setAttribute('aria-expanded', 'false'); expandable.classList.remove('MergeBoxExpandable-module__isExpanded__WZlhA'); expandable.style.visibility = 'hidden'; chevron.style.transform = 'rotate(180deg)'; } else { toggleBtn.setAttribute('aria-expanded', 'true'); expandable.classList.add('MergeBoxExpandable-module__isExpanded__WZlhA'); expandable.style.visibility = 'visible'; chevron.style.transform = ''; } }); wrapper.querySelectorAll('.codeowner-more-btn').forEach(btn => { btn.addEventListener('click', () => { const hiddenId = btn.dataset.hiddenId; const moreCount = btn.dataset.moreCount; const hiddenEl = document.getElementById(hiddenId); if (!hiddenEl) return; const isHidden = hiddenEl.style.display === 'none'; if (isHidden) { hiddenEl.style.display = 'contents'; btn.textContent = 'less'; } else { hiddenEl.style.display = 'none'; btn.textContent = moreCount + ' more'; } }); }); } function iconApproved() { return ` `; } function iconPending() { return ` `; } function avatar(user) { const u = user.replace('@', ''); return ` ${user} `; } function ownersToggleHtml(owners, idPrefix, threshold = 14) { if (!owners || owners.length === 0) { return '-'; } if (owners.length <= threshold) { return owners.map(o => avatar(o)).join(''); } const visible = owners.slice(0, threshold); const hidden = owners.slice(threshold); const moreCount = hidden.length; const hiddenId = idPrefix + '-hidden'; const btnId = idPrefix + '-btn'; return `${visible.map(o => avatar(o)).join('')}`; } function getApprovedList() { const results = {}; const reviewerRows = document.querySelectorAll('form.js-issue-sidebar-form p.d-flex'); reviewerRows.forEach(row => { const nameEl = row.querySelector('span[data-hovercard-type="user"]'); if (!nameEl) return; const userName = nameEl.dataset.assigneeName; const isApproved = row.querySelector('svg.octicon-check.color-fg-success, svg.octicon-check.color-fg-muted') !== null; results[userName] = isApproved; }); return results; } async function main() { if (!isPRPage()) { return; } const gen = _generation; const codeowner = await getCodeOwners(); if (gen !== _generation) return; if (codeowner === false) { insertNoCodeOwnersSection(); return; } if (!codeowner) { return; } console.log(`location: ${window.location.href}`); const changed = await getFilesChanged(); if (gen !== _generation) return; console.log(`codeowner: ${codeowner}`); console.log(`changed: ${changed}`); const codeowners = findCodeOwners(codeowner, changed); console.log(`result: ${JSON.stringify(codeowners, null, 2)}`); const resolved = await resolveTeamOwners(codeowners); if (gen !== _generation) return; console.log(`resolved: ${JSON.stringify(resolved, null, 2)}`); const approvedList = getApprovedList(); console.log(`approvedList: ${JSON.stringify(approvedList, null, 2)}`); insertCodeOwnerSection(resolved); } // ページ遷移・SPA ナビゲーションの管理 let _lastUrl = null; let _generation = 0; let _navTimer = null; let _reviewObserver = null; // Reviews セクションの出現を監視し、スケルトンを即時挿入してデータ取得を開始する function watchForPRContent() { if (_reviewObserver) { _reviewObserver.disconnect(); _reviewObserver = null; } if (!isPRPage()) return; function tryInit() { if (document.querySelector('div[data-testid="mergebox-partial"]')) { insertSkeletonSection(); main(); return true; } return false; } if (!tryInit()) { _reviewObserver = new MutationObserver(() => { if (tryInit()) { _reviewObserver.disconnect(); _reviewObserver = null; } }); _reviewObserver.observe(document.body, { childList: true, subtree: true }); } } function onUrlChange() { const url = window.location.href; if (url === _lastUrl) return; _lastUrl = url; _generation++; // 古いセクションを削除 const existing = document.querySelector('div[data-codeowner-section="true"]'); if (existing) existing.remove(); watchForPRContent(); } function scheduleNavCheck() { if (_navTimer) clearTimeout(_navTimer); _navTimer = setTimeout(() => { _navTimer = null; onUrlChange(); }, 200); } // 初期実行 scheduleNavCheck(); // ページ遷移を検出(SPA対応) window.addEventListener('popstate', scheduleNavCheck); // History API による遷移も検出 const originalPushState = window.history.pushState; const originalReplaceState = window.history.replaceState; window.history.pushState = function(...args) { originalPushState.apply(window.history, args); scheduleNavCheck(); }; window.history.replaceState = function(...args) { originalReplaceState.apply(window.history, args); scheduleNavCheck(); }; })();