Last active
September 22, 2026 19:39
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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) => ({ | |
| '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', | |
| })[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.