GitHub Issue Status Highlighter (GraphQL Ultimate)

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

נכון ליום 06-03-2026. ראה הגרסה האחרונה.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         GitHub Issue Status Highlighter (GraphQL Ultimate)
// @namespace    http://tampermonkey.net/
// @version      0.3
// @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';

    // =========================================================================
    // 🔑 請在這裡填寫你的 GitHub Personal Access Token (必須要有)
    // 取得網址: https://github.com/settings/personal-access-tokens
    // =========================================================================
    const GITHUB_TOKEN = ''; // 例:'ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
    // =========================================================================

    const CACHE_PREFIX = 'gh_graphql_cache_v16_';
    const CACHE_TTL = 1000 * 60 * 60; // 快取 1 小時

    let fetchQueue = [];
    let isProcessingQueue = false;

    // --- 英文縮寫風格時間 ---
    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)`;
    }

    // --- 封裝 GraphQL 請求 ---
    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('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('網路錯誤'))
            });
        });
    }

    // --- 更新視覺外觀 (顏色、泡泡) ---
    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'; // 綠色 (>7天)
        if (diffDays <= 3) boxColor = '#e53935'; // 紅色 (3天內)
        else if (diffDays <= 7) boxColor = '#d4a72c'; // 黃色 (一週內)

        if (isClosed) boxColor = '#6e7781'; // 關閉則反灰

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

    // --- 核心:GraphQL 批次處理列隊 ---
    async function processQueue() {
        if (isProcessingQueue || fetchQueue.length === 0) return;
        isProcessingQueue = true;

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

        // 1. 檢查快取
        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. 組合 GraphQL Query (修復了 URL 解析 Bug)
        if (tasksToFetch.length > 0) {
            let queryNodes = tasksToFetch.map((task, index) => {
                // 使用原生物件完美過濾掉 # 跟 ? 造成的干擾
                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();
    }

    // --- 建立初始 UI ---
    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';

        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 });
        }
    }

    // --- 掃描與注入 ---
    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();
    }

    // --- 防抖與 SPA 監聽優化 ---
    let timeout = null;
    function runWithDebounce() {
        if (timeout) clearTimeout(timeout);
        timeout = setTimeout(highlightIssues, 300);
    }

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

    // 效能優化:將監聽範圍從 document.body 縮小到核心區塊,減少背景吃效能
    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();
    
    // 確保切換頁面後重新綁定 Observer
    document.addEventListener('turbo:load', observeContainer);

})();