// ==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 });
})();