GitHub Issue Status Highlighter (GraphQL Ultimate)

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

Versión del día 06/03/2026. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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);

})();