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