Skip to content

Instantly share code, notes, and snippets.

@kaylesworth
Last active September 22, 2026 19:39
Show Gist options
  • Select an option

  • Save kaylesworth/2091fabf6e6ac46529efdd9cc77ac228 to your computer and use it in GitHub Desktop.

Select an option

Save kaylesworth/2091fabf6e6ac46529efdd9cc77ac228 to your computer and use it in GitHub Desktop.
GitHub — Copy Stacked PRs as Markdown, its slop edit/copy as you wish
// ==UserScript==
// @name GitHub — Copy Stacked PRs as Markdown
// @namespace kevin.aylesworth
// @version 0.5.2
// @description Adds a Copy button to GitHub's native stacked-PR overlay. Copies as markdown (text/plain) and HTML (text/html) so it pastes cleanly into GitHub, Slack, Linear, Teams, Gmail, and Notion.
// @match https://github.com/*
// @run-at document-idle
// @grant none
// @updateURL https://gist.githubusercontent.com/kaylesworth/2091fabf6e6ac46529efdd9cc77ac228/raw/github-copy-stacked-prs.js
// @downloadURL https://gist.githubusercontent.com/kaylesworth/2091fabf6e6ac46529efdd9cc77ac228/raw/github-copy-stacked-prs.js
// ==/UserScript==
(function () {
'use strict';
const FOOTER = '[class*="StackState-module__previewFooter__"]';
const PR_LINK = 'a[class*="prc-ActionList-ActionListContent-"][href*="/pull/"]';
const PR_TITLE = '[data-component="ActionList.Item.Label"]';
const PR_DESCRIPTION = '[data-component="ActionList.Description"]';
const BASE_BRANCH_ITEM = '[class*="StackState-module__branchItem__"]';
const BASE_BRANCH_NAME = '[data-component="BranchName"]';
const MARKER = 'data-stack-copy-injected';
const log = (...args) => console.log('[stack-copy]', ...args);
function extractBase(overlayRoot) {
const item = overlayRoot.querySelector(BASE_BRANCH_ITEM);
const name = item?.querySelector(BASE_BRANCH_NAME)?.textContent.trim();
return name ? { name } : null;
}
function extractStack(overlayRoot) {
const rows = overlayRoot.querySelectorAll(PR_LINK);
const prs = Array.from(rows).map((a) => {
const url = new URL(a.getAttribute('href'), location.origin).href;
const description = a.querySelector(PR_DESCRIPTION)?.textContent.trim() ?? '';
const branch = description.split('·').pop()?.trim() ?? '';
return {
title: a.querySelector(PR_TITLE)?.textContent.trim() ?? '',
url,
number: url.match(/\/pull\/(\d+)/)?.[1] ?? '',
branch,
isCurrent: a.getAttribute('aria-current') === 'page',
};
});
return { prs, base: extractBase(overlayRoot) };
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[c]);
}
function formatStackAsMarkdown({ prs, base }) {
const lines = prs.map((pr) => `- [#${pr.number} ${pr.title}](${pr.url})`);
if (base) {
lines.push(`- ${base.name}`);
}
return lines.join('\n');
}
function formatStackAsHtml({ prs, base }) {
const items = prs.map(
(pr) => `<li><a href="${pr.url}">#${escapeHtml(pr.number)} ${escapeHtml(pr.title)}</a></li>`,
);
if (base) {
items.push(`<li>${escapeHtml(base.name)}</li>`);
}
return `<ul>${items.join('')}</ul>`;
}
async function copyStack(overlayRoot, btn) {
const stack = extractStack(overlayRoot);
log('extracted', stack);
if (stack.prs.length === 0) {
btn.textContent = 'No PRs found';
setTimeout(() => (btn.textContent = '📋 Copy stack as markdown'), 1500);
return;
}
const md = formatStackAsMarkdown(stack);
const html = formatStackAsHtml(stack);
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([md], { type: 'text/plain' }),
'text/html': new Blob([html], { type: 'text/html' }),
}),
]);
const prev = btn.textContent;
btn.textContent = '✓ Copied!';
setTimeout(() => (btn.textContent = prev), 1200);
} catch (err) {
console.error('[stack-copy] clipboard.write failed, falling back to writeText', err);
try {
await navigator.clipboard.writeText(md);
btn.textContent = '✓ Copied (markdown only)';
setTimeout(() => (btn.textContent = '📋 Copy stack as markdown'), 1500);
} catch (err2) {
console.error('[stack-copy] writeText fallback also failed', err2);
btn.textContent = 'Error — see console';
}
}
}
function makeCopyButton(overlayRoot) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '📋 Copy stack as markdown';
btn.style.cssText = [
'font: inherit',
'font-size: 12px',
'padding: 3px 10px',
'border-radius: 6px',
'border: 1px solid var(--borderColor-default, #d0d7de)',
'background: var(--bgColor-default, #f6f8fa)',
'color: inherit',
'cursor: pointer',
'margin-left: auto',
].join(';');
btn.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
copyStack(overlayRoot, btn);
});
return btn;
}
function tryInjectInto(footer) {
if (!footer || footer.hasAttribute(MARKER)) return;
const overlayRoot =
footer.closest('[data-component="AnchoredOverlay"]') ??
footer.parentElement?.parentElement ??
footer;
footer.setAttribute(MARKER, 'true');
const inner = footer.firstElementChild ?? footer;
inner.appendChild(makeCopyButton(overlayRoot));
log('injected into footer');
}
function scan(root) {
if (!(root instanceof Element) && root !== document) return;
if (root instanceof Element && root.matches?.(FOOTER)) {
tryInjectInto(root);
return;
}
root.querySelectorAll?.(FOOTER).forEach(tryInjectInto);
}
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) scan(node);
}
});
observer.observe(document.body, { childList: true, subtree: true });
scan(document);
log('userscript loaded, watching for stack overlays');
})();

Comments are disabled for this gist.