GitHub Issue Status Highlighter (GraphQL Ultimate)

Fast status fetching using GraphQL and Tokens, unified box widths, English relative time, and optimized performance.

2026/03/06のページです。最新版はこちら

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         GitHub Issue Status Highlighter (GraphQL Ultimate)
// @namespace    http://tampermonkey.net/
// @version      0.3.2
// @description  Fast status fetching using GraphQL and Tokens, unified box widths, English relative time, and optimized performance.
// @author       joey&gemini
// @license MIT
// @match        https://github.com/*/*/issues*
// @match        https://github.com/*/*/pulls*
// @match        https://github.com/issues*
// @match        https://github.com/pulls*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_xmlhttpRequest
// @connect      api.github.com
// ==/UserScript==

(function() {
    'use strict';

    // =========================================================================
    // 🔑 REQUIRED: Insert your GitHub Personal Access Token below.
    // Generate one at: https://github.com/settings/personal-access-tokens
    // =========================================================================
    const GITHUB_TOKEN = ''; 
    // =========================================================================

    const CACHE_PREFIX = 'gh_graphql_cache_v16_';
    const CACHE_TTL = 1000 * 60 * 60; // Cache expiration: 1 hour

    let fetchQueue = [];
    let isProcessingQueue = false;

    // Formats date into a condensed relative time string
    function getRelativeTimeText(dateStr) {
        const diffMs = new Date() - new Date(dateStr);
        const diffMins = Math.floor(diffMs / 60000);

        if (diffMins < 1) return 'Just now';
        if (diffMins < 60) return `${diffMins} min(s)`;

        const diffHours = Math.floor(diffMins / 60);
        if (diffHours < 24) return `${diffHours} hr(s)`;

        const diffDays = Math.floor(diffHours / 24);
        if (diffDays < 30) return `${diffDays} day(s)`;

        const diffMonths = Math.floor(diffDays / 30);
        if (diffMonths < 12) return `${diffMonths} mo(s)`;

        const diffYears = Math.floor(diffDays / 365);
        return `${diffYears} yr(s)`;
    }

    // Wrapper for the authenticated GraphQL POST request
    async function fetchGraphQL(query) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: 'https://api.github.com/graphql',
                headers: {
                    'Authorization': `Bearer ${GITHUB_TOKEN}`,
                    'Content-Type': 'application/json'
                },
                data: JSON.stringify({ query }),
                onload: (res) => {
                    if (res.status === 401) reject(new Error('Invalid or missing Token'));
                    else if (res.status !== 200) reject(new Error(`HTTP ${res.status}`));
                    else {
                        const json = JSON.parse(res.responseText);
                        if (json.errors) reject(new Error(json.errors[0].message));
                        else resolve(json.data);
                    }
                },
                onerror: () => reject(new Error('Network Error'))
            });
        });
    }

    // Updates the visual appearance (color and tooltip) based on activity age
    function updateBoxVisuals(box, issueData, isOpen, isClosed) {
        const { timeStr, author } = issueData;
        const issueDate = new Date(timeStr);
        const diffDays = (new Date().getTime() - issueDate.getTime()) / (1000 * 60 * 60 * 24);

        let boxColor = '#2da44e'; // Green (>7 days)
        if (diffDays <= 3) boxColor = '#e53935'; // Red (<=3 days)
        else if (diffDays <= 7) boxColor = '#d4a72c'; // Yellow (<=7 days)

        if (isClosed) boxColor = '#6e7781'; // Grayscale if closed

        box.innerText = getRelativeTimeText(timeStr);
        box.style.backgroundColor = boxColor;
        box.title = `Last comment: @${author}\nTime: ${issueDate.toLocaleString('en-US', { hour12: false })}`;
        box.style.cursor = 'help';
    }

    // Processes the queue in batches using GraphQL aliases
    async function processQueue() {
        if (isProcessingQueue || fetchQueue.length === 0) return;
        isProcessingQueue = true;

        const batchSize = 25;
        const currentBatch = fetchQueue.splice(0, batchSize);
        const tasksToFetch = [];

        // 1. Check cache
        currentBatch.forEach(task => {
            const cached = localStorage.getItem(CACHE_PREFIX + task.url);
            if (cached) {
                try {
                    const parsed = JSON.parse(cached);
                    if (Date.now() - parsed.timestamp < CACHE_TTL) {
                        updateBoxVisuals(task.box, parsed.data, task.isOpen, task.isClosed);
                        return;
                    }
                } catch(e) {}
            }
            tasksToFetch.push(task);
        });

        // 2. Build GraphQL query for un-cached items
        if (tasksToFetch.length > 0) {
            let queryNodes = tasksToFetch.map((task, index) => {
                // Use URL object to safely parse owner, repo, and number, ignoring # and ?
                const urlObj = new URL(task.url);
                const pathParts = urlObj.pathname.split('/'); 
                const owner = pathParts[1];
                const repo = pathParts[2];
                const num = pathParts[4];
                
                return `idx${index}: repository(owner: "${owner}", name: "${repo}") {
                    issueOrPullRequest(number: ${num}) {
                        ... on Issue { updatedAt createdAt author { login } comments(last: 1) { nodes { author { login } createdAt } } }
                        ... on PullRequest { updatedAt createdAt author { login } comments(last: 1) { nodes { author { login } createdAt } } }
                    }
                }`;
            }).join('\n');

            try {
                const data = await fetchGraphQL(`query { ${queryNodes} }`);
                
                tasksToFetch.forEach((task, index) => {
                    const item = data[`idx${index}`]?.issueOrPullRequest;
                    if (!item) {
                        task.box.innerText = 'Error';
                        task.box.style.backgroundColor = '#cf222e';
                        return;
                    }

                    const lastComment = item.comments?.nodes?.[0];
                    const result = lastComment 
                        ? { timeStr: lastComment.createdAt, author: lastComment.author?.login || 'Unknown' }
                        : { timeStr: item.createdAt || item.updatedAt, author: item.author?.login || 'Unknown' };

                    localStorage.setItem(CACHE_PREFIX + task.url, JSON.stringify({ data: result, timestamp: Date.now() }));
                    updateBoxVisuals(task.box, result, task.isOpen, task.isClosed);
                });
            } catch (err) {
                console.error("GraphQL Error:", err);
                tasksToFetch.forEach(task => {
                    task.box.innerText = 'Failed';
                    task.box.style.backgroundColor = '#cf222e';
                    task.box.title = `Error: ${err.message}`;
                });
            }
        }

        isProcessingQueue = false;
        if (fetchQueue.length > 0) processQueue();
    }

    // Creates the initial 'Loading' UI element
    function createInitialBox(statusIcon, url, isOpen, isClosed) {
        const wrapper = statusIcon.parentElement;
        const box = document.createElement('span');
        box.style.cssText = `
            display: inline-block; box-sizing: border-box; width: 72px; height: 22px; line-height: 18px;
            border: 2px solid #fff; border-radius: 4px; text-align: center;
            font-size: 11px; font-weight: bold; color: #fff; background-color: #6e7781;
            font-family: 'Courier New', monospace; box-shadow: 2px 2px 0 rgba(0,0,0,0.5);
            margin-right: 8px; flex-shrink: 0; white-space: nowrap; overflow: hidden;
        `;

        box.innerText = GITHUB_TOKEN ? 'Loading' : 'No Token';
        if (!GITHUB_TOKEN) box.style.backgroundColor = '#cf222e'; // Show error if token is missing

        statusIcon.style.display = 'none';
        wrapper.style.cssText += 'display: flex; align-items: center; min-width: 75px; flex-shrink: 0; overflow: visible;';

        if (!wrapper.querySelector('.my-pixel-box')) {
            box.className = 'my-pixel-box';
            wrapper.appendChild(box);
            if (GITHUB_TOKEN) fetchQueue.push({ url, box, isOpen, isClosed });
        }
    }

    // Scans the DOM for issue/PR links and initializes UI
    function highlightIssues() {
        const links = document.querySelectorAll('a[href*="/issues/"], a[href*="/pull/"]');
        links.forEach(link => {
            const href = link.getAttribute('href');
            if (!/^\/[^\/]+\/[^\/]+\/(issues|pull)\/\d+/.test(href) || link.hasAttribute('data-gh-v16-done')) return;
            link.setAttribute('data-gh-v16-done', 'true');

            const row = link.closest('[data-testid="list-view-item"], div[role="row"], .js-issue-row, li');
            const statusIcon = row?.querySelector('svg[aria-label*="Open" i], svg[aria-label*="Closed" i], svg[aria-label*="Completed" i], svg.octicon-issue-opened, svg.octicon-issue-closed, svg.octicon-git-pull-request');
            if (!statusIcon) return;

            const ariaLabel = (statusIcon.getAttribute('aria-label') || '').toLowerCase();
            const isOpen = ariaLabel.includes('open') || statusIcon.classList.contains('octicon-issue-opened') || statusIcon.classList.contains('octicon-git-pull-request');
            const isClosed = !isOpen;
            
            createInitialBox(statusIcon, link.href, isOpen, isClosed);
            if (isClosed) { 
                row.style.opacity = '0.5'; 
                row.style.filter = 'grayscale(100%)'; 
            }
        });

        if (GITHUB_TOKEN && fetchQueue.length > 0 && !isProcessingQueue) processQueue();
    }

    // Debounce execution for SPA navigation
    let timeout = null;
    function runWithDebounce() {
        if (timeout) clearTimeout(timeout);
        timeout = setTimeout(highlightIssues, 300);
    }

    runWithDebounce();
    document.addEventListener('turbo:render', runWithDebounce);
    document.addEventListener('turbo:load', runWithDebounce);

    // Restrict MutationObserver scope to the main content container to optimize performance
    const observer = new MutationObserver(runWithDebounce);
    function observeContainer() {
        const container = document.querySelector('#repo-content-pjax-container') || document.querySelector('.repository-content') || document.body;
        observer.disconnect();
        observer.observe(container, { childList: true, subtree: true });
    }
    observeContainer();
    
    document.addEventListener('turbo:load', observeContainer);

})();