GitHub PR Copy Title + Link

Adds a button by the PR title that copies the PR link - a named link showing the title in Slack/Notion, the plain URL in editors

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

Advertisement:

// ==UserScript==
// @name         GitHub PR Copy Title + Link
// @namespace    http://tampermonkey.net/
// @icon         https://github.githubassets.com/favicons/favicon-dark.png
// @version      2026.06.15.5
// @description  Adds a button by the PR title that copies the PR link - a named link showing the title in Slack/Notion, the plain URL in editors
// @author       KakkoiDev
// @match        https://github.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

// Broad @match needed: GitHub uses SPA navigation, so the MutationObserver
// re-adds the button as the PR header re-renders.
//
// navigator.clipboard.write (not GM_setClipboard): we write BOTH text/html
// (<a href>) and text/plain (the URL) in one ClipboardItem. Slack and Notion
// read the HTML and render a named link showing the title; plain-text targets
// (markdown files, code editors, anything with no rich paste) get the URL - a
// working link, not an unlinked title. Mirrors Notion's native "Copy link".
// GM_setClipboard writes a single plain flavor only. No fetch -> no GM grants.

(function() {
    'use strict';

    const COPY_SVG = '<svg data-component="Octicon" aria-hidden="true" focusable="false" class="octicon octicon-copy" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" display="inline-block" overflow="visible" style="vertical-align: text-bottom;"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg>';
    const CHECK_SVG = '<svg data-component="Octicon" aria-hidden="true" focusable="false" class="octicon octicon-check" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" display="inline-block" overflow="visible" style="vertical-align: text-bottom;"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg>';

    const BTN_ID = 'gh-copy-title-link-btn';
    const TITLE_TEXT = 'Copy title with link';

    function getPrInfo() {
        const m = location.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
        if (!m) return null;
        return { owner: m[1], repo: m[2], number: m[3] };
    }

    function esc(s) {
        return s.replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;');
    }

    function flash(btn) {
        btn.innerHTML = CHECK_SVG;
        btn.title = 'Copied!';
        btn.setAttribute('aria-label', 'Copied!');
        setTimeout(() => {
            btn.innerHTML = COPY_SVG;
            btn.title = TITLE_TEXT;
            btn.setAttribute('aria-label', TITLE_TEXT);
        }, 1200);
    }

    async function copyTitleLink(btn) {
        const pr = getPrInfo();
        if (!pr) return;
        const titleSpan = document.querySelector('h1[data-component="PH_Title"] span.markdown-title');
        if (!titleSpan) return;

        const title = titleSpan.textContent.trim();
        const url = `https://github.com/${pr.owner}/${pr.repo}/pull/${pr.number}`;
        const label = `${title} #${pr.number}`;
        const html = `<a href="${esc(url)}">${esc(label)}</a>`;

        // text/plain is the URL, not the title: plain-text / dumb editors then
        // paste a working link instead of unlinked title text. Slack / Notion
        // ignore plain and render the html anchor (title as link text). Putting
        // markdown here instead would make paste-as-link editors double-wrap it
        // -> [[label](url)](url), so keep plain a bare URL.
        try {
            await navigator.clipboard.write([
                new ClipboardItem({
                    'text/html': new Blob([html], { type: 'text/html' }),
                    'text/plain': new Blob([url], { type: 'text/plain' }),
                }),
            ]);
            flash(btn);
        } catch (e) {
            // Older browsers / no ClipboardItem: URL only.
            try {
                await navigator.clipboard.writeText(url);
                flash(btn);
            } catch (e2) {
                btn.title = 'Copy failed';
                setTimeout(() => { btn.title = TITLE_TEXT; }, 1200);
            }
        }
    }

    function addCopyButton() {
        const pr = getPrInfo();
        if (!pr) return;
        if (document.getElementById(BTN_ID)) return;

        const editBtn = document.querySelector('h1[data-component="PH_Title"] button[data-component="IconButton"]');
        if (!editBtn) return;

        const btn = document.createElement('button');
        btn.id = BTN_ID;
        btn.type = 'button';
        btn.className = editBtn.className;
        btn.title = TITLE_TEXT;
        btn.setAttribute('aria-label', TITLE_TEXT);
        btn.innerHTML = COPY_SVG;
        btn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            copyTitleLink(btn);
        });

        editBtn.insertAdjacentElement('afterend', btn);
    }

    addCopyButton();

    const observer = new MutationObserver(() => {
        addCopyButton();
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();