GitHub PR List: Show Reviewers

Display reviewers with avatars next to PR title

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GitHub PR List: Show Reviewers
// @version      1.3
// @description  Display reviewers with avatars next to PR title
// @license      GPL
// @match        https://github.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @namespace https://github.com/Myouboku
// ==/UserScript==

(function() {
    'use strict';

    const TOKEN_KEY = 'gh_token_v2';
    let token = GM_getValue(TOKEN_KEY);

    if (!token) {
        token = prompt(
            'GitHub token missing.\n' +
            'Create a token at: https://github.com/settings/tokens\n' +
            'Required permissions: repo (read)\n\n' +
            'Paste your token:'
        );

        if (!token || token.trim() === '') {
            console.log('GitHub PR Reviewers: token not provided');
            return;
        }

        token = token.trim();
        GM_setValue(TOKEN_KEY, token);
        alert('Token saved. Reload the page.');
        return;
    }

    const style = document.createElement('style');
    style.textContent = `
    .gh-reviewer-container {
        margin-left: 8px;
        display: inline-flex;
        align-items: center;
        vertical-align: middle;
    }
    .gh-reviewer-container a {
        text-decoration: none;
    }
    .gh-reviewer-avatar {
        width: 20px;
        height: 20px;
        border-radius: 50%;
        margin-left: -4px;
        background-color: var(--color-canvas-subtle);
        transition: margin-left 0.2s ease, transform 0.1s ease;
        position: relative;
        z-index: 1;
        box-shadow: 0 0 0 2px #6e7681;
    }
    .gh-reviewer-container a:first-child .gh-reviewer-avatar {
        margin-left: 0;
    }
    .gh-reviewer-container:hover a:not(:first-child) .gh-reviewer-avatar {
        margin-left: 8px;
    }
    .gh-reviewer-avatar:hover {
        transform: scale(1.2);
        z-index: 10;
    }
    .status-APPROVED {
        box-shadow: 0 0 0 2px #2da44e !important;
    }
    .status-CHANGES_REQUESTED {
        box-shadow: 0 0 0 2px #cf222e !important;
    }
    .status-COMMENTED {
        box-shadow: 0 0 0 2px #6e7681 !important;
    }
    .status-PENDING {
        box-shadow: 0 0 0 2px #D29922 !important;
    }
    `;
    document.head.appendChild(style);

    let reviewersData = {};
    let containersByPR = {};

    function processRows() {
        if (!window.location.href.includes('/pulls')) return;

        const repoMatch = window.location.pathname.match(
            /^\/([^/]+)\/([^/]+)\/pulls/
        );
        const isGlobalPage = window.location.pathname === '/pulls' ||
              window.location.pathname.startsWith('/pulls?');

        if (!repoMatch && !isGlobalPage) return;

        const prRows = Array.from(
            document.querySelectorAll(
                '.js-issue-row:not([data-rv-loaded="true"])'
            )
        );

        if (prRows.length === 0) return;

        const prsByRepo = new Map();

        prRows.forEach(row => {
            const link = row.querySelector(
                'a.js-navigation-open[data-hovercard-type="pull_request"]'
            );
            if (!link || row.offsetParent === null) return;

            row.setAttribute('data-rv-loaded', 'true');

            const prPath = link.getAttribute('href').split('/');
            const owner = prPath[1];
            const repo = prPath[2];
            const prNumber = parseInt(prPath[4]);

            const container = document.createElement('span');
            container.className = 'gh-reviewer-container';
            container.innerText = '..';

            containersByPR[prNumber] = container;
            link.parentNode.insertBefore(container, link.nextSibling);

            if (reviewersData[prNumber]) {
                renderReviewers(container, reviewersData[prNumber]);
            } else {
                const repoKey = `${owner}/${repo}`;
                if (!prsByRepo.has(repoKey)) {
                    prsByRepo.set(repoKey, { owner, repo, numbers: [] });
                }
                prsByRepo.get(repoKey).numbers.push(prNumber);
            }
        });

        prsByRepo.forEach(({ owner, repo, numbers }) => {
            fetchPRsReviewers(owner, repo, numbers);
        });
    }

    function fetchPRsReviewers(owner, repo, prNumbers) {
        const prFragments = prNumbers.map((num, i) => `
    pr${i}: pullRequest(number: ${num}) {
        number
        reviewRequests(first: 10) {
            nodes {
                requestedReviewer {
                    ... on User {
                        login
                        avatarUrl
                        url
                    }
                    ... on Bot {
                        login
                        avatarUrl
                        url
                    }
                }
            }
        }
        latestReviews(first: 10) {
            nodes {
                author {
                    ... on User {
                        login
                        avatarUrl
                        url
                    }
                    ... on Bot {
                        login
                        avatarUrl
                        url
                    }
                }
                state
                submittedAt
            }
        }
    }
`).join('\n');

        const query = `
        query($owner: String!, $repo: String!) {
            repository(owner: $owner, name: $repo) {
                ${prFragments}
            }
        }
    `;

        console.log("request");
        GM_xmlhttpRequest({
            method: "POST",
            url: "https://api.github.com/graphql",
            headers: {
                "Authorization": `Bearer ${token}`,
                "Content-Type": "application/json"
            },
            data: JSON.stringify({ query, variables: { owner, repo } }),
            onload: (res) => {
                if (res.status !== 200) {
                    console.error('GraphQL failed:', res.responseText);
                    return;
                }

                const data = JSON.parse(res.responseText);

                if (data.errors) {
                    console.error('GraphQL errors:', data.errors);
                    return;
                }

                const repository = data.data?.repository;
                if (!repository) return;

                Object.keys(repository).forEach(key => {
                    const pr = repository[key];
                    if (!pr || !pr.number) return;

                    const uniqueUsers = new Map();

                    (pr.latestReviews?.nodes || []).forEach(r => {
                        if (r.author && r.state !== 'DISMISSED') {
                            uniqueUsers.set(r.author.login, {
                                login: r.author.login,
                                avatar: r.author.avatarUrl,
                                url: r.author.url,
                                state: r.state,
                            });
                        }
                    });

                    (pr.reviewRequests?.nodes || []).forEach(rr => {
                        const reviewer = rr.requestedReviewer;
                        if (reviewer) {
                            uniqueUsers.set(reviewer.login, {
                                login: reviewer.login,
                                avatar: reviewer.avatarUrl,
                                url: reviewer.url,
                                state: 'PENDING',
                            });
                        }
                    });

                    reviewersData[pr.number] = Object.fromEntries(uniqueUsers);

                    const container = containersByPR[pr.number];
                    if (container) {
                        renderReviewers(container, reviewersData[pr.number]);
                    }
                });
            },
            onerror: (err) => {
                console.error('GraphQL request failed:', err);
            }
        });
    }

    function renderReviewers(container, users) {
        container.innerText = '';

        const usersMap =
              users instanceof Map ?
              users :
        new Map(Object.entries(users));

        if (usersMap.size === 0) {
            container.remove();
            return;
        }

        const prLink = container.previousSibling;
        const prPath = prLink?.getAttribute('href')?.split('/');
        const owner = prPath?.[1];
        const repo = prPath?.[2];

        usersMap.forEach(user => {
            if (user.state === 'DISMISSED') return;

            const link = document.createElement('a');
            link.href = `https://github.com/${owner}/${repo}/pulls?q=is%3Apr+reviewed-by%3A${user.login}+is%3Aopen`;
            link.style.textDecoration = 'none';

            const img = document.createElement('img');
            img.src = user.avatar;
            img.title = `${user.login} [${user.state}]`;
            img.className = `gh-reviewer-avatar status-${user.state}`;

            link.appendChild(img);
            container.appendChild(link);
        });
    }

    setInterval(processRows, 200);
})();