GitHub Actions Copy Logs

Copy GitHub Actions step logs: hover header to show icon; click expands step, progressively scrolls to render all lines, then copies. Includes debug logs.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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: <check-step ... data-conclusion="..." ...>
  - 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 = `
      <svg viewBox="0 0 16 16" aria-hidden="true">
        <path d="M2 2.75A1.75 1.75 0 0 1 3.75 1h6.5C11.216 1 12 1.784 12 2.75V4H6.75A1.75 1.75 0 0 0 5 5.75v6.25H3.75A1.75 1.75 0 0 1 2 10.25v-7.5ZM6.75 5.5h6.5c.966 0 1.75.784 1.75 1.75v6c0 .966-.784 1.75-1.75 1.75h-6.5A1.75 1.75 0 0 1 5 13.25v-6c0-.966.784-1.75 1.75-1.75Z"></path>
      </svg>
    `;

        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 <check-step> 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);
    }
})();