// ==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 = `
`;
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 = `
${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()}
-
${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 `
`;
}
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('')}${hidden.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();
};
})();