GitHub Issue Status Highlighter (GraphQL Ultimate)

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

Version au 06/03/2026. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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

})();