GitHub PR Squasher

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

当前为 2025-01-07 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         GitHub PR Squasher
// @namespace    https://github.com/balakumardev/github-pr-squasher
// @version      1.1.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==

/*
===========================================
GitHub PR Squasher
===========================================

A userscript that adds a "Squash & Recreate PR" button to GitHub pull requests.
It creates a new PR with squashed commits while preserving the original description.

Features:
- One-click PR squashing
- Preserves PR description
- Automatically closes original PR and deletes the branch cleaning up
- Secure token storage
- Progress indicators

Installation:
1. Install Tampermonkey or Greasemonkey
2. Install this script
3. Set your GitHub token (click Tampermonkey icon → Set GitHub Token)
4. Refresh GitHub
5. Look for the "Squash & Recreate PR" button on PR pages

GitHub Token Instructions:
1. Go to GitHub Settings → Developer Settings → Personal Access Tokens → Tokens (classic)
2. Generate new token (classic)
3. Select 'repo' permission
4. Copy token and paste it in the script settings

Support: [email protected]
*/

(function() {
    'use strict';

    const DEBUG = true;

    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_"');
            }
        }
    });

    GM_registerMenuCommand('Set Enterprise Domain', async () => {
        const currentDomain = GM_getValue('github_enterprise_domain', '');
        const domain = prompt(
            'Enter your GitHub Enterprise domain (leave empty for github.com):\nExample: github.mycompany.com',
            currentDomain
        );
        if (domain !== null) {
            await GM_setValue('github_enterprise_domain', domain.trim());
            alert('Domain saved! Please refresh the page.');
        }
    });

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

    function getGitHubDomain() {
        const enterpriseDomain = GM_getValue('github_enterprise_domain', '').trim();
        if (enterpriseDomain && window.location.hostname.includes(enterpriseDomain)) {
            return enterpriseDomain;
        }
        return 'github.com';
    }

    function getAPIBaseURL() {
        const domain = getGitHubDomain();
        if (domain === 'github.com') {
            return 'https://api.github.com';
        }
        return `https://${domain}/api/v3`;
    }

    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();
        const baseURL = getAPIBaseURL();
        const url = endpoint.startsWith('http') ? endpoint : `${baseURL}${endpoint}`;

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: method,
                url: url,
                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
                    });

                    if (response.status >= 200 && response.status < 300 || (method === 'DELETE' && response.status === 404)) {
                        resolve(response.responseText ? 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(),
                description: document.querySelector('.comment-body')?.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);

            button.innerHTML = '⏳ Getting tree...';
            const headCommit = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/commits/${prDetails.head.sha}`);
            debugLog('Head Commit:', headCommit);

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

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

            button.innerHTML = '⏳ Creating squashed commit...';
            const newCommit = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/commits`, 'POST', {
                message: `${prInfo.title}\n\nSquashed commits from #${prInfo.prNumber}`,
                tree: headCommit.tree.sha,
                parents: [prDetails.base.sha]
            });
            debugLog('New Commit:', newCommit);

            button.innerHTML = '⏳ Updating branch...';
            await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs/heads/${newBranchName}`, 'PATCH', {
                sha: newCommit.sha,
                force: true
            });

            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}_`
            });

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

            try {
                await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs/heads/${prInfo.branch}`, 'DELETE');
                debugLog('Deleted original branch:', prInfo.branch);
            } catch (error) {
                debugLog('Failed to delete original branch:', error);
            }

            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);
            }
        }
    }

    addSquashButton();

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

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