GitHub PR to Markdown

Downloads a GitHub pull request conversation as a Markdown file.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         GitHub PR to Markdown
// @namespace    https://github.com/Aiuanyu/GeminiChat2MD
// @version      0.3
// @description  Downloads a GitHub pull request conversation as a Markdown file.
// @author       Aiuanyu & Jules
// @match        https://github.com/*/*/pull/*
// @grant        none
// @license      MIT
// @history      0.3 2025-12-21 - Added a dialog to set the title before downloading.
// @history      0.2 Initial release with PR conversation extraction
// @history      0.1 Development version
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_VERSION = '0.3';

    function addStyles() {
        const css = `
            .download-markdown-button {
                position: fixed;
                bottom: 20px;
                right: 20px;
                background-color: #2da44e; /* GitHub Green */
                color: white;
                border: none;
                border-radius: 50%;
                width: 60px;
                height: 60px;
                font-size: 24px;
                cursor: pointer;
                box-shadow: 0 4px 8px rgba(0,0,0,0.2);
                z-index: 10000;
                display: flex;
                align-items: center;
                justify-content: center;
            }
            .download-markdown-button:hover {
                background-color: #2c974b;
            }
        `;
        const styleSheet = document.createElement("style");
        styleSheet.innerText = css;
        document.head.appendChild(styleSheet);
    }

    function createButton() {
        const button = document.createElement("button");
        button.innerText = "MD";
        button.title = "Download as Markdown";
        button.className = "download-markdown-button";
        button.onclick = downloadMarkdown;
        document.body.appendChild(button);
    }

    function getSanitizedTitle() {
        const titleElement = document.querySelector('.gh-header-title .js-issue-title');
        const prNumber = document.querySelector('.gh-header-title .f1-light')?.textContent?.trim() || '';
        const title = titleElement ? titleElement.textContent.trim() : 'GitHub PR';
        return `${prNumber.replace('#', '')} ${title}`;
    }

    function sanitizeFilename(name) {
        return name.replace(/[\/\\?%*:|"<>]/g, '-');
    }

    function escapeYamlString(str) {
        return str.replace(/"/g, '\\"');
    }

    function showTitlePrompt(defaultTitle, callback) {
        let title = prompt("Enter the title for the Markdown file:", defaultTitle);
        if (title === null) {
            return; // User cancelled
        }
        if (title.trim() === '') {
            title = defaultTitle;
        }
        callback(title);
    }

    function nodeToMarkdown(node) {
        if (node.nodeType === Node.TEXT_NODE) {
            return node.textContent;
        }
        if (node.nodeType !== Node.ELEMENT_NODE) {
            return '';
        }

        const el = node;
        let childrenMarkdown = '';
        el.childNodes.forEach(child => {
            childrenMarkdown += nodeToMarkdown(child);
        });

        const tagName = el.tagName.toLowerCase();

        switch (tagName) {
            case 'p': return childrenMarkdown + '\n\n';
            case 'a':
                // Handle user/issue mentions
                if (el.classList.contains('issue-link')) {
                    return `[${childrenMarkdown}](${el.href})`;
                }
                return `[${childrenMarkdown}](${el.href})`;
            case 'strong': case 'b': return `**${childrenMarkdown}**`;
            case 'em': case 'i': return `*${childrenMarkdown}*`;
            case 'del': return `~~${childrenMarkdown}~~`;
            case 'code': return el.closest('pre') ? childrenMarkdown : `\`${childrenMarkdown}\``;
            case 'br': return '\n';
            case 'ul':
                let ul_items = '';
                el.childNodes.forEach(li => {
                    if (li.nodeName === 'LI') ul_items += `* ${nodeToMarkdown(li).trim()}\n`;
                });
                return ul_items;
            case 'ol':
                let ol_items = '';
                let itemIndex = 1;
                el.childNodes.forEach(li => {
                    if (li.nodeName === 'LI') ol_items += `${itemIndex++}. ${nodeToMarkdown(li).trim()}\n`;
                });
                return ol_items;
            case 'li': return `${childrenMarkdown}`;
            case 'pre':
                const lang = (el.querySelector('code')?.className.match(/language-(\S+)/) || [])[1] || '';
                return `\n\`\`\`${lang}\n${el.textContent.trim()}\n\`\`\`\n\n`;
            case 'blockquote':
                return `> ${childrenMarkdown.replace(/\n/g, '\n> ')}\n\n`;
            case 'h1': return `# ${childrenMarkdown}\n\n`;
            case 'h2': return `## ${childrenMarkdown}\n\n`;
            case 'h3': return `### ${childrenMarkdown}\n\n`;
            case 'h4': return `#### ${childrenMarkdown}\n\n`;
            case 'hr': return '---\n\n';
            case 'table':
                let tableMd = '';
                const headers = Array.from(el.querySelectorAll('thead th, th')).map(th => th.textContent.trim());
                if (headers.length > 0) {
                    tableMd += `| ${headers.join(' | ')} |\n`;
                    tableMd += `| ${headers.map(() => '---').join(' | ')} |\n`;
                }
                const rows = el.querySelectorAll('tbody tr');
                rows.forEach(row => {
                    const cells = Array.from(row.querySelectorAll('td')).map(td => htmlToMarkdown(td).trim().replace(/\n/g, '<br>'));
                    tableMd += `| ${cells.join(' | ')} |\n`;
                });
                return tableMd + '\n';
            default: return childrenMarkdown;
        }
    }

    function htmlToMarkdown(element) {
        if (!element) return '';
        let markdown = '';
        element.childNodes.forEach(node => {
            markdown += nodeToMarkdown(node);
        });
        return markdown.replace(/\n\s*\n/g, '\n\n').trim();
    }

    function handleCommit(el) {
        const titleEl = el.querySelector('.TimelineItem-body code a');
        const commitHash = el.querySelector('.TimelineItem-body .text-right a')?.textContent.trim() || '';
        if (!titleEl) return '';

        const title = titleEl.title || titleEl.textContent.trim();
        const description = title.split('\n\n').slice(1).join('\n\n');

        let markdown = `> [!commit] **Commit: \`${commitHash}\`**\n`;
        markdown += `> **${title.split('\n\n')[0]}**\n`;
        if (description) {
            markdown += `>\n> ${description.replace(/\n/g, '\n> ')}\n`;
        }
        return markdown + '\n';
    }

    function handleSystemEvent(el) {
        const text = el.querySelector('.TimelineItem-body')?.textContent.trim().replace(/\s+/g, ' ');
        if (!text) return '';
        return `> *System Event: ${text}*\n\n`;
    }

    function extractContent(title) {
        const titleEl = document.querySelector('.gh-header-title .js-issue-title');
        const prNumber = document.querySelector('.gh-header-title .f1-light')?.textContent?.trim() || '';
        const author = document.querySelector('.gh-header-meta .author')?.textContent.trim() || 'unknown';
        const status = document.querySelector('.gh-header-meta .State')?.textContent.trim() || 'unknown';
        const labels = Array.from(document.querySelectorAll('.js-issue-labels .IssueLabel')).map(l => l.textContent.trim());
        const headRef = document.querySelector('.head-ref')?.textContent.trim() || 'unknown';
        const baseRef = document.querySelector('.base-ref')?.textContent.trim() || 'unknown';

        let markdown = `---
parser: "GitHub PR to Markdown v${SCRIPT_VERSION}"
title: "${escapeYamlString(title)}"
number: ${prNumber.replace('#', '')}
url: "${window.location.href}"
author: ${author}
status: "${status}"
head: "${headRef}"
base: "${baseRef}"
labels: [${labels.map(l => `"${l.replace(/"/g, '\\"')}"`).join(', ')}]
---

# PR: ${title} (${prNumber})

`;

        const timeline = document.querySelectorAll('#discussion_bucket .TimelineItem');
        let commentCount = 0;

        for (const item of timeline) {
            // Regular comments and the PR description
            if (item.querySelector('.timeline-comment-group')) {
                commentCount++;
                const author = item.querySelector('.author')?.textContent.trim() || 'Unknown';
                const timestamp = item.querySelector('relative-time')?.getAttribute('datetime') || '';
                const body = item.querySelector('.comment-body');
                if (!body) continue;

                if (commentCount === 1) {
                    markdown += `## Description\n\n_By ${author} on ${timestamp}_\n\n`;
                } else {
                    markdown += `## Comment ${commentCount - 1}\n\n_By ${author} on ${timestamp}_\n\n`;
                }
                markdown += htmlToMarkdown(body) + '\n\n---\n\n';
            }
            // Commit Messages
            else if (item.querySelector('.octicon-git-commit')) {
                 markdown += handleCommit(item);
            }
            // System events like adding labels, etc.
            else if (item.querySelector('.TimelineItem-badge .octicon-cross-reference, .TimelineItem-badge .octicon-tag')) {
                 markdown += handleSystemEvent(item);
            }
        }

        return markdown.replace(/\n{3,}/g, '\n\n').trim();
    }

    function downloadMarkdown() {
        const defaultTitle = getSanitizedTitle();
        showTitlePrompt(defaultTitle, (title) => {
            const markdownContent = extractContent(title);
            const blob = new Blob([markdownContent], { type: 'text/markdown;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `${sanitizeFilename(title)}.md`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        });
    }

    // Use a MutationObserver to wait for the page to be ready
    const observer = new MutationObserver((mutations, obs) => {
        if (document.querySelector('.gh-header-title')) {
            addStyles();
            createButton();
            obs.disconnect(); // Stop observing once the element is found
        }
    });

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