// ==UserScript== // @name GitHub Actions Copy Logs // @namespace https://github.com/yutengjing/user-scripts // @version 0.4.1 // @description Copy GitHub Actions step logs: hover header to show icon; click expands step, progressively scrolls to render all lines, then copies. Includes debug logs. // @author JingGe helper // @match https://github.com/*/*/actions/runs/*/job/* // @match https://github.com/*/*/commit/*/checks/* // @grant GM_setClipboard // @run-at document-idle // @noframes // ==/UserScript== /* GitHub UI notes: - Each step root: - Step header: summary.CheckStep-header - Logs container: .js-checks-log-display-container - Log line text: .js-check-step-line .js-check-line-content Behavior: - Inject a small Copy button inside step header; only visible on header :hover/:focus-within - Works for all steps (success/failure) - On click: prevent summary toggle; expand → stabilize by repeated scrollIntoView → collect log lines → copy */ (function () { 'use strict'; // ===== Debug utilities (set DEBUG = true to enable console logs) ===== const DEBUG = false; // set true to enable console logs const LOG_PREFIX = '[GH Actions Copy]'; function log(...args) { if (DEBUG) console.log(LOG_PREFIX, ...args); } // Tunables const CONFIG = { STABLE_THRESHOLD: 10, // times of no new lines before stopping LOOP_DELAY_MS: 90, // delay between scroll attempts MAX_LOOPS: 400, // hard stop guard }; const SELECTORS = { // Apply to all steps (success, failure, etc.) stepRoot: 'check-step', headerSummary: 'summary.CheckStep-header', headerRow: '.d-flex.flex-items-center', details: 'details.Details-element.CheckStep', logsContainer: '.js-checks-log-display-container', logLines: '.js-check-step-line .js-check-line-content', truncatedNotice: '.js-checks-log-display-truncated', }; init(); function init() { injectStyle(); scanAndEnhance(); observeMutations(); hookGhNav(); } function injectStyle() { if (document.head.querySelector('style[data-ghac]')) return; // avoid duplicate styles on Turbo const css = ` /* Inline small icon placed before time; only visible on hover */ .ghac-copy-btn{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;margin-right:8px;border-radius:3px;border:1px solid transparent;color:var(--fgColor-muted,#57606a);background:transparent;cursor:pointer;opacity:0;visibility:hidden;pointer-events:none;transition:opacity .12s ease} .ghac-copy-btn:hover{background:var(--control-transparent-bgColor-hover,rgba(175,184,193,0.2));box-shadow:0 0 0 5px var(--control-transparent-bgColor-hover,rgba(175,184,193,0.1))} summary.CheckStep-header:hover .ghac-copy-btn,summary.CheckStep-header:focus-within .ghac-copy-btn{opacity:1;visibility:visible;pointer-events:auto} .ghac-copy-btn svg{width:16px;height:16px;fill:currentColor} .ghac-toast{position:fixed;z-index:9999;left:50%;bottom:24px;transform:translateX(-50%);background:var(--overlay-bgColor,rgba(27,31,36,0.9));color:#fff;padding:8px 12px;border-radius:6px;font-size:12px;box-shadow:0 8px 24px rgba(140,149,159,0.2)} `; const style = document.createElement('style'); style.setAttribute('data-ghac', ''); style.textContent = css; document.head.appendChild(style); } const busySteps = new WeakSet(); function scanAndEnhance(root = document) { const steps = root.querySelectorAll(SELECTORS.stepRoot); steps.forEach(ensureButton); } function observeMutations() { const mo = new MutationObserver((muts) => { for (const m of muts) { for (const node of m.addedNodes) { if (!(node instanceof HTMLElement)) continue; if (node.matches && node.matches(SELECTORS.stepRoot)) { ensureButton(node); } node.querySelectorAll?.(SELECTORS.stepRoot).forEach(ensureButton); } } }); mo.observe(document.body, { childList: true, subtree: true }); } function hookGhNav() { // GitHub uses Turbo/partial reloads const rerun = () => setTimeout(scanAndEnhance, 50); window.addEventListener('turbo:load', rerun); window.addEventListener('turbo:render', rerun); document.addEventListener('pjax:end', rerun); window.addEventListener('popstate', rerun); } function ensureButton(stepEl) { if (!(stepEl instanceof HTMLElement)) return; const header = stepEl.querySelector(SELECTORS.headerSummary); if (!header) return; if (header.querySelector('.ghac-copy-btn')) return; const row = header.querySelector(SELECTORS.headerRow) || header; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'ghac-copy-btn'; btn.title = 'Copy this step logs'; btn.setAttribute('aria-label', 'Copy this step logs'); btn.innerHTML = ` `; btn.addEventListener( 'click', async (e) => { e.stopPropagation(); e.preventDefault(); if (busySteps.has(stepEl)) { log('Skip click: step is busy'); return; } busySteps.add(stepEl); const ok = await copyStepLogs(stepEl).catch(() => false); busySteps.delete(stepEl); toast(ok ? 'Copied step logs ✅' : 'Copy failed ❌'); }, { capture: true }, ); // Insert before the time element to avoid impacting title width const timeEl = row.querySelector('.text-mono.text-normal.text-small.float-right'); if (timeEl) { timeEl.parentNode.insertBefore(btn, timeEl); } else { row.appendChild(btn); } const name = stepEl.getAttribute('data-name') || '(unknown)'; const num = stepEl.getAttribute('data-number') || '?'; log('Injected copy button into step', { num, name }); } async function copyStepLogs(stepEl) { // Expand first to ensure logs are loaded await expandStepAndWait(stepEl); // Ensure virtualized content fully renders by repeated scrollIntoView monitoring await loadAllLinesByRepeatedScroll(stepEl); // Gather all lines after stabilization const text = collectAllCurrentlyRenderedLines(stepEl); if (!text) return false; try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); return true; } } catch {} try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; } } catch {} // Fallback to execCommand const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.top = '-1000px'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.focus(); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; } // (removed) Legacy progressive scroll collector function collectAllCurrentlyRenderedLines(stepEl) { const map = new Map(); stepEl.querySelectorAll('.js-check-step-line').forEach((line) => { const numEl = line.querySelector('.CheckStep-line-number'); const contentEl = line.querySelector('.js-check-line-content'); const num = parseInt((numEl?.textContent || '').trim(), 10); const txt = (contentEl?.innerText || '').trim(); if (!Number.isNaN(num) && txt && !map.has(num)) map.set(num, txt); }); const nums = Array.from(map.keys()).sort((a, b) => a - b); return nums.map((n) => map.get(n)).join('\n'); } async function loadAllLinesByRepeatedScroll(stepEl) { const container = stepEl.querySelector(SELECTORS.logsContainer); if (!container) return; const initialNext = getNextStep(stepEl); let stable = 0; let lastCount = 0; let lastMax = 0; let loops = 0; let mutated = false; const mo = new MutationObserver((muts) => { for (const m of muts) { if (m.addedNodes && m.addedNodes.length) { mutated = true; } } }); mo.observe(container, { childList: true, subtree: true }); const readMetrics = () => { const lines = stepEl.querySelectorAll('.js-check-step-line'); const count = lines.length; let max = 0; if (count) { const last = lines[lines.length - 1]; const n = parseInt( (last.querySelector('.CheckStep-line-number')?.textContent || '').trim(), 10, ); if (!Number.isNaN(n)) max = n; } return { count, max }; }; const curNum = stepEl.getAttribute('data-number') || '?'; const nextNum = initialNext?.getAttribute?.('data-number') || null; log('Stabilize scroll start', { curStep: curNum, nextStep: nextNum, hasNext: !!initialNext, }); while (loops < CONFIG.MAX_LOOPS) { loops++; // Jump scroll: bring next step (or end) into view fast const nextStep = getNextStep(stepEl); if (nextStep && nextStep.scrollIntoView) { // Scroll the immediate next into view to trigger virtualization nextStep.scrollIntoView({ block: 'start', behavior: 'auto' }); } else { // No next step: bring current step end / last line into view const lastLine = stepEl.querySelector('.js-check-step-line:last-child'); if (lastLine && lastLine.scrollIntoView) { lastLine.scrollIntoView({ block: 'end', behavior: 'auto' }); } else { stepEl.scrollIntoView({ block: 'end', behavior: 'auto' }); } } await delay(CONFIG.LOOP_DELAY_MS); const { count, max } = readMetrics(); const nextNow = getNextStep(stepEl); const nextNowNum = nextNow?.getAttribute?.('data-number') || null; const progressed = count > lastCount || max > lastMax || mutated; log('Stabilize loop', { loops, count, max, progressed, mutated, hasNext: !!nextNow, nextStepNum: nextNowNum, }); mutated = false; if (progressed) { stable = 0; lastCount = count; lastMax = max; } else { stable++; } if (stable >= CONFIG.STABLE_THRESHOLD) { log('Stabilize done', { loops, finalCount: lastCount, finalMax: lastMax }); break; } } mo.disconnect(); } // (helpers removed: getScrollRoot/getScrollTop/scrollToY/yOfElementInScroll) function getNextStep(stepEl) { if (!(stepEl instanceof Element)) return null; // Prefer the official logs scroll container const container = stepEl.closest('.WorkflowRunLogsScroll') || stepEl.parentElement; if (container) { // Build an ordered list of sibling check-steps within the container const steps = Array.from(container.querySelectorAll('check-step')); const idx = steps.indexOf(stepEl); if (idx >= 0 && idx + 1 < steps.length) return steps[idx + 1]; } // Fallback: walk nextElementSibling chain let n = stepEl.nextElementSibling; while (n) { if (n.matches && n.matches('check-step')) return n; n = n.nextElementSibling; } return null; } async function expandStepAndWait(stepEl) { const details = stepEl.querySelector(SELECTORS.details); if (!details) return null; if (!details.open) { const summary = details.querySelector(SELECTORS.headerSummary); if (summary) { // Trigger GitHub's lazy loader by simulating a click on summary log('Expanding step via summary click'); summary.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); } else { log('Expanding step by setting details.open'); details.open = true; // fallback } } // Wait for logs container to be present and not hidden const container = await waitFor( () => { const c = stepEl.querySelector(SELECTORS.logsContainer); if (!c) return null; if (c.hasAttribute('hidden')) return null; return c; }, 5000, 100, ); log('Logs container ready?', { ready: !!container }); // Then wait for at least one line (best effort) const firstLine = await waitFor(() => stepEl.querySelector(SELECTORS.logLines), 2000, 100); log('First log line present?', { present: !!firstLine }); return container; } function delay(ms) { return new Promise((r) => setTimeout(r, ms)); } function waitFor(condFn, timeoutMs = 1000, interval = 50) { return new Promise((resolve) => { const start = Date.now(); const id = setInterval(() => { const el = condFn(); if (el || Date.now() - start > timeoutMs) { clearInterval(id); resolve(el); } }, interval); }); } function toast(message, ms = 1600) { const t = document.createElement('div'); t.className = 'ghac-toast'; t.textContent = message; document.body.appendChild(t); setTimeout(() => { t.remove(); }, ms); } })();