GitHub PR Squasher

One-click tool to squash GitHub Pull Requests. Creates a new PR with squashed commits and preserves the description.

// ==UserScript==
// @name         GitHub PR Squasher
// @namespace    https://github.com/balakumardev/github-pr-squasher
// @version      2.0
// @description  One-click tool to squash GitHub Pull Requests. Creates a new PR with squashed commits and preserves the description.
// @author       Bala Kumar
// @license      MIT
// @match        https://github.com/*
// @match        https://*.github.com/*
// @match        https://*.github.io/*
// @match        https://*.githubusercontent.com/*
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      api.github.com
// @connect      *
// @supportURL   https://github.com/balakumardev/github-pr-squasher/issues
// @homepage     https://github.com/balakumardev/github-pr-squasher
// ==/UserScript==

(function() {
    'use strict';

    const DEBUG = true;

    // Add settings menu to Tampermonkey
    GM_registerMenuCommand('Set GitHub Token', async () => {
        const token = prompt('Enter your GitHub Personal Access Token (Classic):', GM_getValue('github_token', ''));
        if (token !== null) {
            if (token.startsWith('ghp_')) {
                await GM_setValue('github_token', token);
                alert('Token saved! Please refresh the page.');
            } else {
                alert('Invalid token format. Token should start with "ghp_"');
            }
        }
    });

    function debugLog(...args) {
        if (DEBUG) console.log('[PR Squasher]', ...args);
    }

    async function getGitHubToken() {
        const token = GM_getValue('github_token');
        if (!token) {
            throw new Error('GitHub token not set. Click on the Tampermonkey icon and select "Set GitHub Token"');
        }
        return token;
    }

    async function githubAPI(endpoint, method = 'GET', body = null) {
        debugLog(`API Call: ${method} ${endpoint}`);
        if (body) debugLog('Request Body:', body);

        const token = await getGitHubToken();

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: method,
                url: `https://api.github.com${endpoint}`,
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'Accept': 'application/vnd.github.v3+json',
                    'Content-Type': 'application/json',
                },
                data: body ? JSON.stringify(body) : null,
                onload: function(response) {
                    debugLog(`Response ${endpoint}:`, {
                        status: response.status,
                        statusText: response.statusText,
                        responseText: response.responseText.substring(0, 500) + (response.responseText.length > 500 ? '...' : '')
                    });

                    if (response.status >= 200 && response.status < 300) {
                        resolve(JSON.parse(response.responseText || '{}'));
                    } else {
                        reject(new Error(`GitHub API error: ${response.status} - ${response.responseText}`));
                    }
                },
                onerror: function(error) {
                    debugLog('Request failed:', error);
                    reject(error);
                }
            });
        });
    }

    async function handleSquash() {
        const button = document.getElementById('squash-button');
        button.disabled = true;
        button.innerHTML = '⏳ Starting...';

        try {
            await getGitHubToken();

            const prInfo = {
                owner: window.location.pathname.split('/')[1],
                repo: window.location.pathname.split('/')[2],
                prNumber: window.location.pathname.split('/')[4],
                branch: document.querySelector('.head-ref').innerText.trim(),
                title: document.querySelector('.js-issue-title').innerText.trim(),
                baseBranch: document.querySelector('.base-ref').innerText.trim()
            };
            debugLog('PR Info:', prInfo);

            button.innerHTML = '⏳ Getting PR details...';
            const prDetails = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}`);
            debugLog('PR Details:', prDetails);
            
            // Get the exact PR description from the API to preserve formatting
            prInfo.description = prDetails.body || '';

            // Get the latest base branch commit
            button.innerHTML = '⏳ Getting latest base branch...';
            const latestBaseRef = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs/heads/${prInfo.baseBranch}`);
            const latestBaseSha = latestBaseRef.object.sha;
            debugLog('Latest Base SHA:', latestBaseSha);

            // Get the comparison between base and head
            button.innerHTML = '⏳ Comparing changes...';
            const comparison = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/compare/${prDetails.base.sha}...${prDetails.head.sha}`);
            debugLog('Comparison:', comparison);

            // Create new branch name
            const timestamp = new Date().getTime();
            const newBranchName = `squashed-pr-${prInfo.prNumber}-${timestamp}`;
            debugLog('New Branch Name:', newBranchName);

            // Create new branch from the latest base
            button.innerHTML = '⏳ Creating new branch...';
            await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs`, 'POST', {
                ref: `refs/heads/${newBranchName}`,
                sha: latestBaseSha
            });

            // Get the PR commits to form a proper commit message
            button.innerHTML = '⏳ Getting PR commits...';
            const commits = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}/commits`);
            
            // Create a combined commit message
            let commitMessage = `${prInfo.title}\n\n`;
            if (prInfo.description) {
                commitMessage += `${prInfo.description}\n\n`;
            }
            commitMessage += `Squashed commits from #${prInfo.prNumber}:\n\n`;
            
            commits.forEach(commit => {
                commitMessage += `* ${commit.commit.message.split('\n')[0]}\n`;
            });

            // Get the PR files to apply changes
            button.innerHTML = '⏳ Getting PR changes...';
            const files = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}/files`);
            debugLog(`PR has ${files.length} changed files`);

            // Get the latest tree from the base branch
            const baseTree = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/trees/${latestBaseSha}`);
            
            // Create a new tree with the changes
            button.innerHTML = '⏳ Creating new tree with changes...';
            
            // For each changed file, we need to get its content
            const treeItems = [];
            for (const file of files) {
                if (file.status === 'removed') {
                    // For deleted files, we don't include them in the new tree
                    treeItems.push({
                        path: file.filename,
                        mode: '100644',
                        type: 'blob',
                        sha: null // null SHA means delete the file
                    });
                } else {
                    // For added or modified files, get the content from the head branch
                    const fileContent = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/contents/${file.filename}?ref=${prDetails.head.ref}`);
                    
                    // If the file is binary or too large, use its SHA directly
                    if (fileContent.encoding === 'base64') {
                        treeItems.push({
                            path: file.filename,
                            mode: '100644',
                            type: 'blob',
                            content: atob(fileContent.content.replace(/\s/g, ''))
                        });
                    } else {
                        // For other cases (like submodules or very large files), use the SHA
                        treeItems.push({
                            path: file.filename,
                            mode: '100644',
                            type: 'blob',
                            sha: fileContent.sha
                        });
                    }
                }
            }
            
            // Create a new tree
            const newTree = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/trees`, 'POST', {
                base_tree: latestBaseSha,
                tree: treeItems
            });
            
            // Create the squashed commit
            button.innerHTML = '⏳ Creating squashed commit...';
            const newCommit = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/commits`, 'POST', {
                message: commitMessage,
                tree: newTree.sha,
                parents: [latestBaseSha]
            });
            
            // Update the new branch to point to the squashed commit
            button.innerHTML = '⏳ Updating branch...';
            await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs/heads/${newBranchName}`, 'PATCH', {
                sha: newCommit.sha,
                force: true
            });

            // Create new PR with the exact same description
            button.innerHTML = '⏳ Creating new PR...';
            const newPR = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls`, 'POST', {
                title: `${prInfo.title} (Squashed)`,
                head: newBranchName,
                base: prInfo.baseBranch,
                body: `${prInfo.description}\n\n---\n_Squashed version of #${prInfo.prNumber}_`
            });

            // Close original PR
            button.innerHTML = '⏳ Closing original PR...';
            await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}`, 'PATCH', {
                state: 'closed'
            });

            // Redirect to the new PR
            window.location.href = newPR.html_url;

        } catch (error) {
            console.error('Failed to squash PR:', error);
            debugLog('Error details:', error);
            alert(`Failed to squash PR: ${error.message}\nCheck console for details`);
            button.disabled = false;
            button.innerHTML = '🔄 Squash & Recreate PR';
        }
    }

    function addSquashButton() {
        if (window.location.href.includes('/pull/')) {
            const actionBar = document.querySelector('.gh-header-actions');
            if (actionBar && !document.getElementById('squash-button')) {
                const squashButton = document.createElement('button');
                squashButton.id = 'squash-button';
                squashButton.className = 'btn btn-sm btn-primary';
                squashButton.innerHTML = '🔄 Squash & Recreate PR';
                squashButton.onclick = handleSquash;
                actionBar.appendChild(squashButton);
            }
        }
    }

    // Add button when page loads
    addSquashButton();

    // Add button when navigation occurs
    const observer = new MutationObserver(() => {
        if (window.location.href.includes('/pull/')) {
            addSquashButton();
        }
    });

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址