GitHub Issue Dashboard (Discontinued — Use GitHub Projects instead)

Fast Monitoring GUI using GraphQL search, tabs, custom date range, and tag filtering with dynamic repo tracking.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GitHub Issue Dashboard (Discontinued — Use GitHub Projects instead)
// @namespace    http://tampermonkey.net/
// @version      0.3.5
// @description  Fast Monitoring GUI using GraphQL search, tabs, custom date range, and tag filtering with dynamic repo tracking.
// @author       joey&gemini
// @license      MIT
// @match        https://github.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @connect      api.github.com
// ==/UserScript==

(function() {
    'use strict';

    const CACHE_PREFIX = 'gh_graphql_dashboard_v20_';
    const CACHE_TTL = 1000 * 60 * 15; // Cache expiration set to 15 minutes

    // State Variables
    let githubToken = GM_getValue('GITHUB_PAT', '');
    let allIssues = [];
    let currentView = 'recent';
    let currentTagFilter = 'All';
    let daysMin = 0;
    let daysMax = 3;

    // --- Helper: Dynamically get the Repository from the current URL ---
    function getCurrentRepo() {
        const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)/);
        if (match) {
            // Exclude GitHub system reserved paths to avoid false positives
            const invalidUsers = ['settings', 'notifications', 'explore', 'organizations', 'marketplace', 'sponsors', 'models', 'orgs', 'users'];
            if (!invalidUsers.includes(match[1])) {
                return `${match[1]}/${match[2]}`; // Returns e.g., "facebook/react"
            }
        }
        return null; // Not currently on a Repository page
    }

    // --- Helper: Get ISO format date string (X days ago) ---
    function getTargetDateISO(daysAgo) {
        const targetDate = new Date();
        targetDate.setDate(targetDate.getDate() - daysAgo);
        return targetDate.toISOString().split('T')[0];
    }

    // --- Helper: Format relative time ---
    function getRelativeTimeText(dateStr) {
        if (!dateStr) return 'Unknown';
        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)`;
        return `${Math.floor(diffDays / 30)} mo(s)`;
    }

    // --- Core: Send authenticated GraphQL request ---
    async function fetchGraphQL(query) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: 'https://api.github.com/graphql',
                headers: {
                    'Authorization': `Bearer ${githubToken}`,
                    'Content-Type': 'application/json'
                },
                data: JSON.stringify({ query }),
                onload: (res) => {
                    if (res.status === 401) {
                        reject(new Error('UNAUTHORIZED'));
                    } 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'))
            });
        });
    }

    // --- GraphQL Search Query ---
    async function searchIssues(viewType, minD, maxD, repo) {
        const dateOlder = getTargetDateISO(maxD);
        const dateNewer = getTargetDateISO(minD);
        const dateWeekly = getTargetDateISO(7);

        let searchQuery = viewType === 'recent'
            ? `repo:${repo} is:issue updated:${dateOlder}..${dateNewer}`
            : `repo:${repo} is:issue updated:<${dateWeekly} state:open`;

        // Add repo name to cache key to prevent cross-contamination between projects
        const safeRepoName = repo.replace('/', '_');
        const cacheKey = `${CACHE_PREFIX}${safeRepoName}_${viewType}` + (viewType === 'recent' ? `_${minD}_${maxD}` : '');
        const cached = localStorage.getItem(cacheKey);

        if (cached) {
            try {
                const parsed = JSON.parse(cached);
                if (Date.now() - parsed.timestamp < CACHE_TTL) return parsed.data;
            } catch (e) {}
        }

        const gqlQuery = `query {
          search(query: "${searchQuery}", type: ISSUE, first: 50) {
            nodes {
              ... on Issue {
                title url state updatedAt createdAt
                author { login }
                repository { nameWithOwner }
                labels(first: 5) { nodes { name } }
              }
            }
          }
        }`;

        const data = await fetchGraphQL(gqlQuery);
        const results = data.search.nodes || [];
        localStorage.setItem(cacheKey, JSON.stringify({ data: results, timestamp: Date.now() }));
        return results;
    }

    // --- UI: Render Token Setup Screen ---
    function renderTokenSetupUI(errorMsg = '') {
        const content = document.getElementById('issue-dashboard-content');
        content.innerHTML = `
            <div style="padding: 20px; color: #24292f;">
                <h3 style="margin-top: 0; margin-bottom: 12px; font-size: 16px; border-bottom: 1px solid #d0d7de; padding-bottom: 8px;">GitHub Token Authentication Required</h3>
                <p style="font-size: 13px; margin-bottom: 12px; color: #57606a;">To read project status, please provide a Personal Access Token (PAT).</p>
                <div style="margin-bottom: 16px;">
                    <a href="https://github.com/settings/tokens/new?description=Issues+Monitoring+Dashboard&scopes=repo" target="_blank" style="color: #0969da; text-decoration: none; font-size: 13px; font-weight: 600; border: 1px solid #d0d7de; padding: 6px 12px; border-radius: 4px; display: inline-block; background-color: #f6f8fa;">Generate Token</a>
                </div>
                <input type="password" id="issue-token-input" placeholder="Paste token here (ghp_...)" style="width: 100%; padding: 6px 8px; box-sizing: border-box; border: 1px solid #d0d7de; border-radius: 4px; font-size: 13px; margin-bottom: 12px;">
                <button id="issue-token-save-btn" style="width: 100%; padding: 6px 16px; background-color: #2da44e; color: #fff; border: 1px solid rgba(27,31,36,0.15); border-radius: 4px; font-weight: 600; cursor: pointer;">Save and Verify</button>
                ${errorMsg ? `<div style="color: #cf222e; font-size: 12px; margin-top: 10px; font-weight: 600;">[Error] ${errorMsg}</div>` : ''}
            </div>
        `;

        document.getElementById('issue-token-save-btn').addEventListener('click', async () => {
            const inputVal = document.getElementById('issue-token-input').value.trim();
            if (!inputVal) return;

            const btn = document.getElementById('issue-token-save-btn');
            btn.innerText = 'Verifying...';
            btn.disabled = true;

            const targetRepo = getCurrentRepo();
            if (!targetRepo) {
                renderTokenSetupUI('Please navigate to a Repository page before verifying.');
                return;
            }

            githubToken = inputVal;
            try {
                // Send a test request (using current page repo)
                await searchIssues('recent', 0, 0, targetRepo);
                GM_setValue('GITHUB_PAT', githubToken);
                updateDashboardData(currentView);
            } catch (err) {
                githubToken = '';
                GM_deleteValue('GITHUB_PAT');
                renderTokenSetupUI(err.message === 'UNAUTHORIZED' ? 'Token invalid or expired. Please regenerate.' : `Verification failed: ${err.message}`);
            }
        });
    }

    // --- UI: Render Dashboard Main Structure ---
    function renderDashboardHTML() {
        const dashboard = document.createElement('div');
        dashboard.id = 'issue-dashboard';
        dashboard.style.cssText = `
            position: fixed; bottom: 20px; right: 20px; width: 450px; height: 550px;
            background-color: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px;
            box-shadow: 0 8px 24px rgba(140,149,159,0.2); z-index: 10000; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;
            display: none; flex-direction: column; overflow: hidden; box-sizing: border-box;
            font-size: 13px; color: #24292f;
        `;

        const headerHTML = `
            <div style="background-color: #f6f8fa; color: #24292f; padding: 12px 16px; border-bottom: 1px solid #d0d7de; font-weight: 600; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center;">
                <span id="issue-dashboard-header-title" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 350px;">Issues Monitoring</span>
                <button id="issue-dashboard-close" style="color: #57606a; background: none; border: none; font-weight: bold; cursor: pointer; padding: 0; flex-shrink: 0;">✕</button>
            </div>
            <div style="display: flex; background-color: #fff; border-bottom: 1px solid #d0d7de; flex-shrink: 0;">
                <button id="issue-dashboard-tab-recent" class="issue-tab issue-tab-active" style="flex: 1; padding: 10px; font-weight: 600; background: none; border: none; cursor: pointer; color: #57606a; border-bottom: 2px solid transparent;">Recent Updates</button>
                <button id="issue-dashboard-tab-weekly" class="issue-tab" style="flex: 1; padding: 10px; font-weight: 600; background: none; border: none; cursor: pointer; color: #57606a; border-bottom: 2px solid transparent;">Needs Follow-up</button>
            </div>
            <div style="padding: 10px 16px; background-color: #fff; border-bottom: 1px solid #d0d7de; flex-shrink: 0; display: flex; gap: 12px; align-items: center;">
                <div id="issue-days-container" style="display:flex; align-items:center; gap:4px;">
                    <label style="font-weight:600; font-size:12px; color:#57606a;">Days:</label>
                    <input type="number" id="issue-dashboard-days-min" value="${daysMin}" min="0" max="365" style="width: 40px; padding: 2px; text-align: center; border: 1px solid #d0d7de; border-radius: 4px; font-size: 12px; color:#24292f; background-color: transparent;">
                    <span style="color:#57606a; font-weight:bold;">-</span>
                    <input type="number" id="issue-dashboard-days-max" value="${daysMax}" min="0" max="365" style="width: 40px; padding: 2px; text-align: center; border: 1px solid #d0d7de; border-radius: 4px; font-size: 12px; color:#24292f; background-color: transparent;">
                </div>
                <div style="flex: 1; display: flex; align-items: center; gap: 6px;">
                    <label for="issue-dashboard-tag-filter" style="font-weight:600; font-size:12px; color:#57606a;">Tag:</label>
                    <select id="issue-dashboard-tag-filter" style="flex: 1; padding: 3px 6px; font-family: inherit; font-size: 12px; border: 1px solid #d0d7de; border-radius: 4px; color:#24292f; background-color: transparent; cursor:pointer;"></select>
                </div>
            </div>
        `;

        const contentHTML = `
            <div id="issue-dashboard-content" style="flex: 1; overflow-y: auto; padding: 0; background-color: #fff;">
                <div style="padding: 16px; text-align: center; color: #57606a;">Loading Dashboard Data...</div>
            </div>
            <div style="padding: 8px 16px; background-color: #f6f8fa; border-top: 1px solid #d0d7de; flex-shrink: 0; display: flex; justify-content: flex-start; align-items: center;">
                <button id="issue-dashboard-refresh" title="Force refresh data" style="background: none; border: none; cursor: pointer; padding: 0; font-size: 18px; color: #57606a; font-weight: bold; transition: transform 0.4s ease, color 0.2s ease; display: flex; align-items: center;">⟲</button>
                <span style="font-size: 11px; color: #8c959f; margin-left: 8px;">Auto-caches for 15 mins</span>
            </div>
        `;

        dashboard.innerHTML = headerHTML + contentHTML;
        document.body.appendChild(dashboard);

        const styles = document.createElement('style');
        styles.innerHTML = `
            .issue-tab-active { color: #24292f !important; border-bottom: 2px solid #fd8c73 !important; }
            .issue-item { border-bottom: 1px solid #d0d7de; padding: 12px 16px; display: flex; flex-direction: column; transition: background-color 0.2s; }
            .issue-item:hover { background-color: #f6f8fa; }
            .issue-label { display:inline-block; margin-right:6px; margin-top:6px; padding:2px 8px; background-color:#ddf4ff; color:#0969da; border-radius:2em; font-size:11px; font-weight:500; }
            input[type=number]::-webkit-inner-spin-button { opacity: 1; }
            #issue-dashboard-refresh:hover { color: #0969da !important; }
        `;
        document.head.appendChild(styles);
    }

    // --- UI: Render toggle floating button ---
    function renderToggleButton() {
        const button = document.createElement('button');
        button.id = 'issue-dashboard-toggle';
        button.innerText = 'Dashboard';
        button.style.cssText = `
            position: fixed; bottom: 30px; right: 30px; padding: 8px 16px;
            background-color: #24292f; color: #fff; border: 1px solid rgba(27,31,36,0.15); border-radius: 6px;
            box-shadow: 0 3px 6px rgba(140,149,159,0.15); font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; font-size: 13px; font-weight: 600;
            cursor: pointer; z-index: 10001; transition: background-color 0.2s;
        `;
        button.onmouseover = () => button.style.backgroundColor = '#424a53';
        button.onmouseout = () => button.style.backgroundColor = '#24292f';
        document.body.appendChild(button);
    }

    // --- UI: Generate Tag dropdown menu ---
    function populateTagDropdown(issues) {
        const dropdown = document.getElementById('issue-dashboard-tag-filter');
        const tags = new Set();
        issues.forEach(issue => {
            issue.labels?.nodes?.forEach(label => tags.add(label.name));
        });

        const sortedTags = Array.from(tags).sort();
        let optionsHTML = '<option value="All">All Tags</option>';
        sortedTags.forEach(tag => {
            optionsHTML += `<option value="${tag}" ${tag === currentTagFilter ? 'selected' : ''}>${tag}</option>`;
        });
        dropdown.innerHTML = optionsHTML;
    }

    // --- UI: Update list content ---
    function updateDashboardContentList(issues) {
        const content = document.getElementById('issue-dashboard-content');
        let filteredIssues = issues;

        if (currentTagFilter !== 'All') {
            filteredIssues = issues.filter(issue =>
                issue.labels?.nodes?.some(label => label.name === currentTagFilter)
            );
        }

        if (filteredIssues.length === 0) {
            content.innerHTML = `
                <div style="text-align: center; color: #57606a; padding-top: 30px;">
                    <div style="margin-bottom: 12px;">No matching issues found for these criteria.</div>
                    <button id="issue-reset-token-btn" style="background: none; border: none; padding: 0; color: #0969da; cursor: pointer; text-decoration: underline; font-size: 12px;">Change Token?</button>
                </div>
            `;

            document.getElementById('issue-reset-token-btn').addEventListener('click', () => {
                githubToken = '';
                GM_deleteValue('GITHUB_PAT');
                renderTokenSetupUI('Please enter a new Token.');
            });
            return;
        }

        let issuesHTML = filteredIssues.map(issue => {
            const timeStr = currentView === 'recent' ? issue.updatedAt : issue.createdAt;
            const author = issue.author?.login || 'Unknown';
            const labels = issue.labels?.nodes?.map(l => `<span class="issue-label">${l.name}</span>`).join('') || '';

            return `
                <div class="issue-item">
                    <a href="${issue.url}" target="_blank" style="text-decoration: none; color: #0969da; font-weight: 600; font-size: 14px; line-height: 1.5;">${issue.title} <span style="color:#57606a; font-weight:normal;">#${issue.url.split('/').pop()}</span></a>
                    <div style="font-size: 12px; color: #57606a; margin-top: 4px;">By @${author} • ${getRelativeTimeText(timeStr)}</div>
                    <div>${labels}</div>
                </div>
            `;
        }).join('\n');

        content.innerHTML = issuesHTML;
    }

    // --- Data Flow: Fetch and Update Status ---
    async function updateDashboardData(viewType) {
        currentView = viewType;
        const daysContainer = document.getElementById('issue-days-container');
        const content = document.getElementById('issue-dashboard-content');
        const headerTitle = document.getElementById('issue-dashboard-header-title');

        daysContainer.style.display = viewType === 'recent' ? 'flex' : 'none';

        // Dynamically fetch current repository
        const targetRepo = getCurrentRepo();

        if (targetRepo) {
            headerTitle.innerText = `Issues (${targetRepo})`;
        } else {
            headerTitle.innerText = `Issues Monitoring`;
            content.innerHTML = '<div style="text-align:center; padding-top:40px; color:#57606a; font-weight:600;">Please navigate to a repository page to view its issues.</div>';
            return; // Stop further fetching
        }

        if (!githubToken) {
            renderTokenSetupUI();
            return;
        }

        content.innerHTML = '<div style="text-align:center; padding-top:30px; color:#57606a;">Loading data...</div>';

        try {
            allIssues = await searchIssues(viewType, daysMin, daysMax, targetRepo);
            populateTagDropdown(allIssues);
            updateDashboardContentList(allIssues);
        } catch (err) {
            if (err.message === 'UNAUTHORIZED') {
                githubToken = '';
                GM_deleteValue('GITHUB_PAT');
                renderTokenSetupUI('Saved Token is invalid or has insufficient permissions. Please reconfigure.');
            } else {
                content.innerHTML = `<div style="text-align: center; color: #cf222e; padding-top: 30px; font-weight: 600;">Failed to load data.<br/><span style="font-weight:normal; font-size:12px;">Error message: ${err.message}</span></div>`;
            }
        }
    }

    // --- Event: Force refresh data ---
    function handleForceRefresh() {
        if (!githubToken) return;

        const targetRepo = getCurrentRepo();
        if (!targetRepo) return;

        const btn = document.getElementById('issue-dashboard-refresh');
        btn.style.transform = 'rotate(-360deg)';
        setTimeout(() => {
            btn.style.transition = 'none';
            btn.style.transform = 'rotate(0deg)';
            setTimeout(() => btn.style.transition = 'transform 0.4s ease, color 0.2s ease', 10);
        }, 400);

        const safeRepoName = targetRepo.replace('/', '_');
        const cacheKey = `${CACHE_PREFIX}${safeRepoName}_${currentView}` + (currentView === 'recent' ? `_${daysMin}_${daysMax}` : '');
        localStorage.removeItem(cacheKey);

        updateDashboardData(currentView);
    }

    // --- UI: Toggle panel logic ---
    function toggleDashboard() {
        const dashboard = document.getElementById('issue-dashboard');
        const toggleBtn = document.getElementById('issue-dashboard-toggle');

        if (dashboard.style.display === 'none') {
            dashboard.style.display = 'flex';
            toggleBtn.style.display = 'none';
            updateDashboardData('recent'); // Re-check URL and status on every open
        } else {
            dashboard.style.display = 'none';
            toggleBtn.style.display = 'block';
        }
    }

    // --- UI: Switch tabs ---
    function switchTab(viewType) {
        document.querySelectorAll('.issue-tab').forEach(t => t.classList.remove('issue-tab-active'));
        if (viewType === 'recent') {
            document.getElementById('issue-dashboard-tab-recent').classList.add('issue-tab-active');
        } else {
            document.getElementById('issue-dashboard-tab-weekly').classList.add('issue-tab-active');
        }
        currentTagFilter = 'All';
        updateDashboardData(viewType);
    }

    // --- UI: Handle day range changes ---
    function handleDateRangeChange() {
        const inputMin = document.getElementById('issue-dashboard-days-min');
        const inputMax = document.getElementById('issue-dashboard-days-max');

        let valMin = parseInt(inputMin.value, 10);
        let valMax = parseInt(inputMax.value, 10);

        if (isNaN(valMin) || valMin < 0) valMin = 0;
        if (isNaN(valMax) || valMax < 0) valMax = 0;

        if (valMax < valMin) {
            valMax = valMin;
            inputMax.value = valMax;
        }

        daysMin = valMin;
        daysMax = valMax;
        updateDashboardData('recent');
    }

    // --- Initialization ---
    function init() {
        if (!document.getElementById('issue-dashboard-toggle')) {
            renderToggleButton();
            renderDashboardHTML();

            document.getElementById('issue-dashboard-toggle').addEventListener('click', toggleDashboard);
            document.getElementById('issue-dashboard-close').addEventListener('click', toggleDashboard);
            document.getElementById('issue-dashboard-refresh').addEventListener('click', handleForceRefresh);
            document.getElementById('issue-dashboard-tab-recent').addEventListener('click', () => switchTab('recent'));
            document.getElementById('issue-dashboard-tab-weekly').addEventListener('click', () => switchTab('weekly'));

            document.getElementById('issue-dashboard-tag-filter').addEventListener('change', (e) => {
                currentTagFilter = e.target.value;
                updateDashboardContentList(allIssues);
            });

            document.getElementById('issue-dashboard-days-min').addEventListener('change', handleDateRangeChange);
            document.getElementById('issue-dashboard-days-max').addEventListener('change', handleDateRangeChange);
        }

        // Automatically update data if dashboard is open during SPA navigation
        const dashboard = document.getElementById('issue-dashboard');
        if (dashboard && dashboard.style.display !== 'none') {
            updateDashboardData(currentView);
        }
    }

    init();

    // Listen for GitHub SPA page transition events (Turbo/Pjax)
    document.addEventListener('turbo:load', init);
    // Compatibility for older GitHub page transition mechanisms
    document.addEventListener('pjax:end', init);

})();