// ==UserScript== // @name GitHub PR Approved Viewer // @namespace http://tampermonkey.net/ // @version 1.8.3 // @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-pr-approved-viewer/main/src/github-pr-approved-viewer.user.js // @downloadURL https://raw.githubusercontent.com/SimplyRin/github-pr-approved-viewer/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('/'); // "wants to merge X commits into {TARGET} from {SOURCE}" のテキストパターンから // ターゲットブランチを取得(DOM のクラス名変更に影響されない方法) let branch = 'main'; // フォールバック const treePrefix = `/${owner}/${repo}/tree/`; const links = document.querySelectorAll(`a[href^="${treePrefix}"]`); for (const link of links) { const parent = link.parentElement; if (parent && /wants to merge .+ into/.test(parent.textContent)) { branch = decodeURIComponent(link.getAttribute('href').slice(treePrefix.length)); break; } } 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'); } // GitHub の CSS Modules のクラス名はビルドごとにハッシュ部分が変わる // (例: "Diff-module__diffHeaderWrapper__UgUyv" → "diffHeaderWrapper__Ab12Cd") // ため、ハッシュを含まない部分一致のセレクタを生成する function moduleClassSelector(name) { return `[class*="${name}__"]`; } // オブジェクトを再帰的に探索し、rawLines 配列を探す function findRawLines(obj, depth = 0) { if (!obj || typeof obj !== 'object' || depth > 10) return null; if (Array.isArray(obj.rawLines) && obj.rawLines.every(l => typeof l === 'string')) { return obj.rawLines; } for (const value of Object.values(obj)) { const found = findRawLines(value, depth + 1); if (found) return found; } return null; } // blob ページの HTML からファイル本文を取り出す(DOM 構成の変更に備えて複数の方法を順に試す) function extractBlobText(doc) { // 1. React アプリの埋め込みデータ (payload.blob.rawLines) for (const script of doc.querySelectorAll('script[data-target="react-app.embeddedData"], script[type="application/json"]')) { try { const lines = findRawLines(JSON.parse(script.textContent)); if (lines) return lines.join('\n'); } catch (e) { // JSON でないスクリプトは無視 } } // 2. 読み取り専用のテキストエリア const textarea = doc.querySelector('#read-only-cursor-text-area'); if (textarea && textarea.value) return textarea.value; // 3. コード表示の各行要素(行単位で取得し、改行で連結する) const cells = doc.querySelectorAll('[data-testid="code-cell"], .react-file-line, td.blob-code'); if (cells.length > 0) { return Array.from(cells, cell => cell.textContent.replace(/\n$/, '')).join('\n'); } return null; } // // 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'); return extractBlobText(doc) || null; } catch (e) { console.error(e); } return null; } async function getFilesChanged() { const doc = await fetchDoc(`${window.location.href}/changes`); const list = []; const add = name => { name = name && name.trim(); if (name && !list.includes(name)) list.push(name); }; // React 版の差分ビュー: ファイルヘッダー内の h3 > code doc.querySelectorAll(`${moduleClassSelector('diffHeaderWrapper')} h3 code`).forEach(code => add(code.textContent)); // 従来版の差分ビュー: data 属性にファイルパスを持つ if (list.length === 0) { doc.querySelectorAll('[data-tagsearch-path], copilot-diff-entry[data-file-path], .file-header[data-path]').forEach(el => { add(el.getAttribute('data-tagsearch-path') || el.getAttribute('data-file-path') || el.getAttribute('data-path')); }); } list.forEach((name, i) => console.log(`diff[${i}]: ${name}`)); 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 commentIdx = parts.findIndex((part, i) => i > 0 && part.startsWith('#')); const owners = commentIdx === -1 ? parts.slice(1) : parts.slice(1, commentIdx); 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(rules[i].pattern); if (regex.test(file.replace(/^\/+/, ''))) { 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; }); } // GitHub のユーザー名・チーム名は大文字小文字を区別しないため、比較用に正規化する function normalizeOwner(owner) { return owner.replace(/^@/, '').toLowerCase(); } function pushUniqueOwner(list, owner) { const key = normalizeOwner(owner); if (!list.some(o => normalizeOwner(o) === key)) list.push(owner); } 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)) { pushUniqueOwner(expanded, member); } } else { pushUniqueOwner(expanded, owner); } } return { ...row, owners: expanded }; }); } function ensureLeadingSlash(path) { if (!path.startsWith('/')) { return '/' + path; } return path; } // CODEOWNERS のパターンを、先頭の "/" を除いたリポジトリ相対パスにマッチする正規表現に変換する // (GitHub の仕様: https://docs.github.com/ja/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners) function patternToRegex(pattern) { // 末尾の "/" はディレクトリ指定(配下の全ファイルにマッチ) const trimmed = pattern.replace(/\/+$/, ''); // 先頭または途中に "/" を含むパターンはリポジトリルート基準、含まないものは任意の階層にマッチ const anchored = trimmed.includes('/'); const body = trimmed.replace(/^\/+/, ''); if (body === '' || body === '*' || body === '**') { // ルートワイルドカード: 全ファイルにマッチ return /^.*$/; } const segments = body.split('/'); const last = segments[segments.length - 1]; let regex = segments.map((segment, i) => { const isLast = i === segments.length - 1; if (segment === '**') { return isLast ? '.*' : '(?:.*/)?'; } const converted = segment.replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '[^/]*') .replace(/\?/g, '[^/]'); return isLast ? converted : converted + '/'; }).join(''); regex = (anchored ? '^' : '^(?:.*/)?') + regex; if (last === '**') { regex += '$'; } else if (last === '*' && segments.length > 1) { // "docs/*" は直下のファイルのみにマッチし、さらに下の階層にはマッチしない regex += '$'; } else { // ファイル名またはディレクトリ名にマッチ(ディレクトリの場合は配下の全ファイル) regex += '(?:/.*)?$'; } return new RegExp(regex); } // マージボックス(Reviews / Merging is blocked などを含む領域)を取得する function findMergeBox() { return document.querySelector('[data-testid="mergebox-partial"]'); } // GitHub 側の CSS Modules(ハッシュ付きクラス名)に依存しないよう、 // 表示に必要なスタイルは独自クラスとして注入する function ensureStyles() { if (document.getElementById('codeowner-styles')) return; const style = document.createElement('style'); style.id = 'codeowner-styles'; style.textContent = ` .codeowner-container { position: relative; } .codeowner-mergeability-icon { display: none; position: absolute; top: 0; left: -55px; width: 40px; height: 40px; align-items: center; justify-content: center; border-radius: var(--borderRadius-medium, 6px); color: var(--fgColor-onEmphasis, #fff); } @media (min-width: 1012px) { .codeowner-mergeability-icon { display: flex; } } .codeowner-box { border: var(--borderWidth-thin, 1px) solid var(--borderColor-default, #d1d9e0); border-radius: var(--borderRadius-medium, 6px); overflow: hidden; } .codeowner-header { position: relative; display: flex; gap: var(--base-size-8, 8px); padding: var(--base-size-16, 16px); } .codeowner-header-can-expand:hover { cursor: pointer; background: var(--control-transparent-bgColor-hover, rgba(208, 215, 222, 0.32)); } .codeowner-header-can-expand:focus-within { outline: 2px solid var(--focus-outlineColor, #0969da); outline-offset: -2px; } .codeowner-header-content { display: flex; flex: 1; gap: var(--base-size-8, 8px); min-width: 0; } .codeowner-heading { margin: 0; font-size: var(--text-body-size-large, 16px); font-weight: var(--base-text-weight-semibold, 600); line-height: 1.5; } .codeowner-header-description { margin: 0; color: var(--fgColor-muted, #59636e); font-size: var(--text-body-size-medium, 14px); } .codeowner-toggle { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; background: none; opacity: 0; cursor: pointer; } .codeowner-chevron { display: flex; transition: transform 0.2s ease-in-out; } .codeowner-chevron.codeowner-is-expanded { transform: rotate(90deg); } .codeowner-expandable { max-height: 300px; overflow-y: auto; padding: var(--base-size-8, 8px); border-top: var(--borderWidth-thin, 1px) solid var(--borderColor-muted, #d1d9e0b3); background-color: var(--bgColor-inset, #f6f8fa); } .codeowner-expandable[hidden] { display: none; } @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); } const SHIELD_LOCK_ICON = ``; // 既存のセクションを置き換えて、マージボックスの直前にセクションの外枠を挿入する function insertSectionShell(mergeBox, { iconBg, borderColor, innerHTML, loading = false }) { ensureStyles(); removeSection(); const wrapper = document.createElement('div'); // マージボックスと同じ余白・インデントにする(ユーティリティクラス名の変更にも追従するため既存要素からコピー) wrapper.className = mergeBox.className || 'tmp-ml-md-6 tmp-pl-md-3 tmp-my-3'; wrapper.setAttribute('data-codeowner-section', 'true'); const container = document.createElement('div'); container.className = 'codeowner-container'; const icon = document.createElement('div'); icon.className = 'codeowner-mergeability-icon'; icon.style.backgroundColor = iconBg; icon.innerHTML = SHIELD_LOCK_ICON; const box = document.createElement('div'); box.className = 'codeowner-box'; if (borderColor) box.style.borderColor = borderColor; const section = document.createElement('section'); section.setAttribute('aria-label', 'Code owner approval status'); if (loading) section.setAttribute('data-codeowner-loading', 'true'); section.innerHTML = innerHTML; box.appendChild(section); container.appendChild(icon); container.appendChild(box); wrapper.appendChild(container); _sectionEl = wrapper; mergeBox.before(wrapper); startSectionObserver(); return section; } function removeSection() { _sectionEl = null; document.querySelectorAll('div[data-codeowner-section="true"]').forEach(el => el.remove()); } // GitHub 側の再描画(React の再マウントなど)で挿入したセクションが取り除かれたり、 // マージボックスが作り直されたりしても、常にマージボックスの直前に表示し続ける function keepSectionAttached() { if (!_sectionEl) return; const mergeBox = findMergeBox(); if (!mergeBox) return; if (_sectionEl.isConnected && _sectionEl.nextElementSibling === mergeBox) return; mergeBox.before(_sectionEl); } function approvalKey(approvedList) { return JSON.stringify(Object.entries(approvedList).sort()); } // サイドバーの Reviewers 欄は遅れて読み込まれたり、承認によって更新されたりするため、 // 承認状態が変わったら結果を再描画する function refreshIfApprovalChanged() { if (!_lastResolved || !_sectionEl) return; if (approvalKey(getApprovedList()) !== _renderedApprovalKey) { insertCodeOwnerSection(_lastResolved); } } function startSectionObserver() { if (_sectionObserver) return; _sectionObserver = new MutationObserver(() => { keepSectionAttached(); if (!_approvalTimer) { _approvalTimer = setTimeout(() => { _approvalTimer = null; refreshIfApprovalChanged(); }, 300); } }); _sectionObserver.observe(document.body, { childList: true, subtree: true }); } function sectionHeaderHtml({ avatarHtml, titleHtml, descriptionHtml, expandableId }) { return `
`; } function circleIconHtml(bgColor, iconSvg) { return `No .github/CODEOWNERS file found in this repository.
', }), }); } function insertSkeletonSection() { const mergeBox = findMergeBox(); if (!mergeBox) return; const skeletonRow = (titleWidth, descWidth) => `${allApproved ? 'All code owners have approved.' : 'Code owner review required.'}
`, expandableId: sectionId, }) + ` `, }); const toggleBtn = container.querySelector('.codeowner-toggle'); const expandable = container.querySelector(`#${sectionId}`); const chevron = container.querySelector('.codeowner-chevron'); toggleBtn.addEventListener('click', () => { const expanded = toggleBtn.getAttribute('aria-expanded') !== 'true'; toggleBtn.setAttribute('aria-expanded', String(expanded)); expandable.hidden = !expanded; chevron.classList.toggle('codeowner-is-expanded', expanded); }); container.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 `