NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Bitbucket PR info to clipboard
// @version 1.2.2
// @copyright 2026 - Milan Farkas / WRD Labs Zrt. (https://www.wrd.hu)
// @namespace [www.wrd.hu](https://www.wrd.hu)
// @description This adds a button to Bitbucket Cloud pull request pages to allow easy copying of PR info (title + link) to the clipboard, as rich text and plain text. Keeps the Jira issue link from the PR title, and also shows up in the sticky header of the diff view. Dependency free, survives SPA navigation. Tested on bitbucket.org.
// @author milanfarkas
// @license MIT
// @match https://bitbucket.org/**/pull-requests/**
// @run-at document-idle
// @grant none
// ==/UserScript==
(() => {
'use strict';
// A class, not an id: the PR page can show two headers at once (the normal one
// and the fixed compact bar of the diff view), and both need their own button.
const BUTTON_CLASS = 'wrd-copy-pr-button';
const STYLE_ID = 'wrd-copy-pr-style';
const TOAST_ID = 'wrd-copy-pr-toast';
const PR_PATH_RE = /^(.*)\/pull-requests\/(\d+)(?:\/|$)/;
const RENDER_DELAY_MS = 250;
// Bitbucket Cloud is a React SPA with generated class names, so every lookup
// goes through a fallback chain instead of a single selector.
const HEADER_SELECTOR = '[data-testid="pr-header"]';
const TITLE_SELECTORS = [
'[data-testid="pr-header"] h1',
'[data-testid="pull-request-header--title"]',
'h1[data-testid="pull-request-title"]',
'[data-qa="pr-header-title"]',
'main h1',
'[role="main"] h1',
'h1',
];
// The "..." menu is present in every PR state, so it is the primary anchor.
const MENU_BUTTON_RE = /^(more actions|more|actions|további műveletek|egyéb műveletek)$/i;
// Only a fallback anchor: these are state dependent, a merged PR has none of them.
// Never used to position the button when the "..." menu is found.
const ACTION_BUTTON_RE = /^(merge|approve|unapprove|request changes|changes requested|decline|resolve conflicts)$/i;
// The "..." menu is a square icon button, unlike the labelled dropdowns
// ("Changes requested", "Approve" with a chevron), which are wide.
const ICON_BUTTON_MAX_SIZE = 48;
const ICON_BUTTON_MAX_SKEW = 6;
// Guards for the sticky bar lookup, so per-file sticky headers never match.
const STICKY_MAX_TOP = 200;
const STICKY_MAX_HEIGHT = 320;
const STICKY_MIN_HEIGHT = 56;
const TITLE_FINGERPRINT_LENGTH = 24;
// Tags kept in the copied rich text; everything else is unwrapped.
const KEPT_TAGS = new Set(['A', 'CODE', 'STRONG', 'B', 'EM', 'I', 'S', 'DEL']);
const SKIPPED_TAGS = new Set(['BUTTON', 'SVG', 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
const injectStyle = () => {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.${BUTTON_CLASS} {
align-items: center;
background: #1868db;
border: none;
border-radius: 3px;
color: #fff;
cursor: pointer;
display: inline-flex;
font: 500 14px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
height: 32px;
padding: 0 12px;
vertical-align: middle;
white-space: nowrap;
}
.${BUTTON_CLASS}:hover { background: #1558bc; }
.${BUTTON_CLASS}:active { background: #123f8f; }
#${TOAST_ID} {
background: #172b4d;
border-radius: 3px;
bottom: 24px;
box-shadow: 0 4px 8px rgba(9, 30, 66, 0.25);
color: #fff;
font: 400 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
max-width: 360px;
opacity: 0;
padding: 10px 14px;
position: fixed;
right: 24px;
transition: opacity 150ms ease-in-out;
word-break: break-word;
z-index: 2147483647;
}
#${TOAST_ID}[data-visible="true"] { opacity: 1; }
#${TOAST_ID}[data-kind="error"] { background: #ae2e24; }
@media (prefers-color-scheme: dark) {
.${BUTTON_CLASS} { background: #579dff; color: #1d2125; }
.${BUTTON_CLASS}:hover { background: #85b8ff; }
#${TOAST_ID} { background: #282e33; }
}
`;
document.head.append(style);
};
let toastTimerId = null;
const showToast = (message, kind = 'success') => {
injectStyle();
let toast = document.getElementById(TOAST_ID);
if (!toast) {
toast = document.createElement('div');
toast.id = TOAST_ID;
document.body.append(toast);
}
clearTimeout(toastTimerId);
toast.dataset.kind = kind;
toast.textContent = message;
requestAnimationFrame(() => {
toast.dataset.visible = 'true';
});
toastTimerId = setTimeout(() => {
toast.dataset.visible = 'false';
setTimeout(() => toast.remove(), 200);
}, 2500);
};
const isVisible = (element) => Boolean(element?.offsetParent) || element?.getClientRects().length > 0;
const collapse = (value) => value.replace(/\s+/g, ' ').trim();
const label = (element) => collapse(element.getAttribute('aria-label') ?? element.textContent ?? '');
const findTitleElement = () => {
for (const selector of TITLE_SELECTORS) {
for (const element of document.querySelectorAll(selector)) {
if (isVisible(element) && element.textContent.trim()) {
return element;
}
}
}
return null;
};
/** First characters of the PR title, used to tell a PR header apart from a file header. */
const titleFingerprint = () => {
const title = findTitleElement();
const text = collapse(title?.textContent ?? '');
return text ? text.slice(0, TITLE_FINGERPRINT_LENGTH) : null;
};
/**
* The header an action button belongs to: either the regular PR header, or the
* fixed/sticky compact bar the diff view shows while scrolling.
*/
const findHeaderRoot = (element, fingerprint) => {
const known = element.closest(HEADER_SELECTOR);
if (known) {
return known;
}
let node = element.parentElement;
for (let depth = 0; node && depth < 10; depth += 1) {
const position = getComputedStyle(node).position;
if (position === 'fixed' || position === 'sticky') {
const rect = node.getBoundingClientRect();
const fits = rect.top <= STICKY_MAX_TOP && rect.height >= STICKY_MIN_HEIGHT && rect.height <= STICKY_MAX_HEIGHT;
if (fits && fingerprint && collapse(node.textContent).includes(fingerprint)) {
return node;
}
}
node = node.parentElement;
}
return null;
};
/**
* Walks up from the anchor button to its Atlassian ButtonGroup. The button is
* always inserted directly in front of the anchor's own wrapper, so with the
* "..." menu as anchor it can never land between two action buttons.
*/
const resolveGroup = (root, anchor, anchorIsMenu) => {
let node = anchor;
for (let depth = 0; node?.parentElement && node.parentElement !== root && depth < 5; depth += 1) {
const parent = node.parentElement;
const style = getComputedStyle(parent);
const isRow = style.display === 'flex' || style.display === 'inline-flex';
const wrapper = node;
// A ButtonGroup: flex row with an explicit gap, so spacing comes for free.
if (isRow && Number.parseFloat(style.columnGap) > 0) {
return {
container: parent,
insert: (button) => {
button.style.marginRight = '0';
if (anchorIsMenu) {
wrapper.before(button);
} else {
// No "..." menu found: go to the end of the group, never between
// two action buttons like Changes requested and Approve.
parent.append(button);
}
},
};
}
// The header row itself: put the button in front of the action side.
if (isRow && style.justifyContent === 'space-between') {
return {
container: parent,
insert: (button) => {
button.style.marginRight = '8px';
wrapper.before(button);
},
};
}
node = parent;
}
return null;
};
/**
* Rates a button as an anchor candidate. Higher wins, 0 means unusable.
* The "..." menu must outrank every labelled dropdown, otherwise the button
* could end up between "Changes requested" and "Approve".
*/
const anchorRank = (button) => {
const text = label(button);
if (MENU_BUTTON_RE.test(text)) {
return 3;
}
if (button.getAttribute('aria-haspopup') === 'true') {
// A square icon button with a popup: the "..." menu under a label we do not
// know (localized UI). Labelled dropdowns are wide, so they never match.
const rect = button.getBoundingClientRect();
const isIcon =
rect.width > 0 && rect.width <= ICON_BUTTON_MAX_SIZE && Math.abs(rect.width - rect.height) <= ICON_BUTTON_MAX_SKEW;
if (isIcon) {
return 2;
}
}
return ACTION_BUTTON_RE.test(text) ? 1 : 0;
};
/** One target per header on screen (regular header, sticky bar, or both). */
const findInsertTargets = () => {
const fingerprint = titleFingerprint();
const candidatesByRoot = new Map();
for (const button of document.querySelectorAll('button')) {
const rank = anchorRank(button);
if (!rank || !isVisible(button)) {
continue;
}
const root = findHeaderRoot(button, fingerprint);
if (!root) {
continue;
}
const candidates = candidatesByRoot.get(root) ?? [];
candidates.push({ button, rank });
candidatesByRoot.set(root, candidates);
}
const targets = [];
for (const [root, candidates] of candidatesByRoot) {
// Best rank wins; on a tie the rightmost button, which is where "..." sits.
const anchor = candidates.reduce((best, current) => {
if (current.rank !== best.rank) {
return current.rank > best.rank ? current : best;
}
return current.button.getBoundingClientRect().right > best.button.getBoundingClientRect().right ? current : best;
});
const target = resolveGroup(root, anchor.button, anchor.rank > 1);
if (target && !targets.some((other) => other.container === target.container)) {
targets.push(target);
}
}
if (targets.length) {
return targets;
}
// Fallback: inline, right after the title text.
const title = findTitleElement();
if (!title) {
return [];
}
return [
{
container: title,
insert: (button) => {
button.style.marginLeft = '12px';
title.append(button);
},
},
];
};
const getPrContext = () => {
const match = PR_PATH_RE.exec(window.location.pathname);
if (!match) {
return null;
}
const [, repoPath, prId] = match;
return { prId, url: `${window.location.origin}${repoPath}/pull-requests/${prId}` };
};
const escapeHtml = (value) =>
value.replace(/[&<>"']/g, (character) => {
const entities = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return entities[character];
});
/**
* Serializes the PR title into clean rich text and plain text at the same time.
* Links (the Jira issue key Bitbucket auto-links) are kept with absolute URLs,
* every React wrapper element, class and inline style is dropped.
*/
const serializeNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return { html: escapeHtml(node.nodeValue), text: node.nodeValue };
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return { html: '', text: '' };
}
const tag = node.tagName.toUpperCase();
if (node.classList.contains(BUTTON_CLASS) || SKIPPED_TAGS.has(tag)) {
return { html: '', text: '' };
}
const parts = [...node.childNodes].map(serializeNode);
const html = parts.map((part) => part.html).join('');
const text = parts.map((part) => part.text).join('');
if (tag === 'A' && node.href) {
return { html: `<a href="${escapeHtml(node.href)}">${html}</a>`, text };
}
if (KEPT_TAGS.has(tag)) {
const name = tag.toLowerCase();
return { html: `<${name}>${html}</${name}>`, text };
}
return { html, text };
};
const legacyCopy = (html, text) => {
const listener = (event) => {
event.clipboardData.setData('text/html', html);
event.clipboardData.setData('text/plain', text);
event.preventDefault();
};
document.addEventListener('copy', listener);
const copied = document.execCommand('copy');
document.removeEventListener('copy', listener);
return copied;
};
const copyToClipboard = async (html, text) => {
if (navigator.clipboard && typeof window.ClipboardItem === 'function') {
try {
await navigator.clipboard.write([
new window.ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text], { type: 'text/plain' }),
}),
]);
return true;
} catch (error) {
console.warn('[Bitbucket PR copy] Clipboard API failed, falling back to execCommand.', error);
}
}
return legacyCopy(html, text);
};
const buildPayload = () => {
const context = getPrContext();
if (!context) {
return null;
}
const titleElement = findTitleElement();
let title;
if (titleElement) {
const serialized = serializeNode(titleElement);
title = { html: collapse(serialized.html), text: collapse(serialized.text) };
} else {
// Last resort: the document title, without the repo and Bitbucket suffix.
const fromDocument = collapse(document.title.split(' — ')[0]);
title = { html: escapeHtml(fromDocument), text: fromDocument };
}
if (!title.text) {
return null;
}
return {
title: title.text,
html: `${title.html}<br><a href="${escapeHtml(context.url)}">${escapeHtml(context.url)}</a>`,
text: `${title.text}\n${context.url}`,
};
};
const handleClick = async (event) => {
event.preventDefault();
const payload = buildPayload();
if (!payload) {
showToast('Nem talalom a PR cimet vagy az azonositot.', 'error');
return;
}
const copied = await copyToClipboard(payload.html, payload.text);
showToast(copied ? payload.title : 'A vagolapra masolas nem sikerult.', copied ? 'success' : 'error');
};
const createButton = () => {
const button = document.createElement('button');
button.className = BUTTON_CLASS;
button.type = 'button';
button.textContent = 'Copy PR';
button.title = 'PR cim es link masolasa a vagolapra';
button.addEventListener('click', handleClick);
return button;
};
const render = () => {
if (!getPrContext()) {
for (const button of document.querySelectorAll(`.${BUTTON_CLASS}`)) {
button.remove();
}
return;
}
const targets = findInsertTargets();
if (!targets.length) {
return;
}
injectStyle();
for (const target of targets) {
if (target.container.querySelector(`.${BUTTON_CLASS}`)) {
continue;
}
target.insert(createButton());
}
};
const scheduleRender = (() => {
let timerId = null;
return () => {
clearTimeout(timerId);
timerId = setTimeout(render, RENDER_DELAY_MS);
};
})();
for (const method of ['pushState', 'replaceState']) {
const original = history[method];
history[method] = function patched(...args) {
const result = original.apply(this, args);
scheduleRender();
return result;
};
}
window.addEventListener('popstate', scheduleRender);
new MutationObserver(scheduleRender).observe(document.body, { childList: true, subtree: true });
render();
})();