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. Перейдите к последней версии.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

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

})();