Fast status fetching using GraphQL and Tokens, unified box widths, English relative time, and optimized performance.
Verze ze dne
// ==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);
})();