GitHub PR Squasher

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

  1. // ==UserScript==
  2. // @name GitHub PR Squasher
  3. // @namespace https://github.com/balakumardev/github-pr-squasher
  4. // @version 2.0
  5. // @description One-click tool to squash GitHub Pull Requests. Creates a new PR with squashed commits and preserves the description.
  6. // @author Bala Kumar
  7. // @license MIT
  8. // @match https://github.com/*
  9. // @match https://*.github.com/*
  10. // @match https://*.github.io/*
  11. // @match https://*.githubusercontent.com/*
  12. // @icon https://github.githubassets.com/favicons/favicon.svg
  13. // @grant GM_xmlhttpRequest
  14. // @grant GM_getValue
  15. // @grant GM_setValue
  16. // @grant GM_registerMenuCommand
  17. // @connect api.github.com
  18. // @connect *
  19. // @supportURL https://github.com/balakumardev/github-pr-squasher/issues
  20. // @homepage https://github.com/balakumardev/github-pr-squasher
  21. // ==/UserScript==
  22.  
  23. (function() {
  24. 'use strict';
  25.  
  26. const DEBUG = true;
  27.  
  28. // Add settings menu to Tampermonkey
  29. GM_registerMenuCommand('Set GitHub Token', async () => {
  30. const token = prompt('Enter your GitHub Personal Access Token (Classic):', GM_getValue('github_token', ''));
  31. if (token !== null) {
  32. if (token.startsWith('ghp_')) {
  33. await GM_setValue('github_token', token);
  34. alert('Token saved! Please refresh the page.');
  35. } else {
  36. alert('Invalid token format. Token should start with "ghp_"');
  37. }
  38. }
  39. });
  40.  
  41. function debugLog(...args) {
  42. if (DEBUG) console.log('[PR Squasher]', ...args);
  43. }
  44.  
  45. async function getGitHubToken() {
  46. const token = GM_getValue('github_token');
  47. if (!token) {
  48. throw new Error('GitHub token not set. Click on the Tampermonkey icon and select "Set GitHub Token"');
  49. }
  50. return token;
  51. }
  52.  
  53. async function githubAPI(endpoint, method = 'GET', body = null) {
  54. debugLog(`API Call: ${method} ${endpoint}`);
  55. if (body) debugLog('Request Body:', body);
  56.  
  57. const token = await getGitHubToken();
  58.  
  59. return new Promise((resolve, reject) => {
  60. GM_xmlhttpRequest({
  61. method: method,
  62. url: `https://api.github.com${endpoint}`,
  63. headers: {
  64. 'Authorization': `Bearer ${token}`,
  65. 'Accept': 'application/vnd.github.v3+json',
  66. 'Content-Type': 'application/json',
  67. },
  68. data: body ? JSON.stringify(body) : null,
  69. onload: function(response) {
  70. debugLog(`Response ${endpoint}:`, {
  71. status: response.status,
  72. statusText: response.statusText,
  73. responseText: response.responseText.substring(0, 500) + (response.responseText.length > 500 ? '...' : '')
  74. });
  75.  
  76. if (response.status >= 200 && response.status < 300) {
  77. resolve(JSON.parse(response.responseText || '{}'));
  78. } else {
  79. reject(new Error(`GitHub API error: ${response.status} - ${response.responseText}`));
  80. }
  81. },
  82. onerror: function(error) {
  83. debugLog('Request failed:', error);
  84. reject(error);
  85. }
  86. });
  87. });
  88. }
  89.  
  90. async function handleSquash() {
  91. const button = document.getElementById('squash-button');
  92. button.disabled = true;
  93. button.innerHTML = '⏳ Starting...';
  94.  
  95. try {
  96. await getGitHubToken();
  97.  
  98. const prInfo = {
  99. owner: window.location.pathname.split('/')[1],
  100. repo: window.location.pathname.split('/')[2],
  101. prNumber: window.location.pathname.split('/')[4],
  102. branch: document.querySelector('.head-ref').innerText.trim(),
  103. title: document.querySelector('.js-issue-title').innerText.trim(),
  104. baseBranch: document.querySelector('.base-ref').innerText.trim()
  105. };
  106. debugLog('PR Info:', prInfo);
  107.  
  108. button.innerHTML = '⏳ Getting PR details...';
  109. const prDetails = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}`);
  110. debugLog('PR Details:', prDetails);
  111. // Get the exact PR description from the API to preserve formatting
  112. prInfo.description = prDetails.body || '';
  113.  
  114. // Get the latest base branch commit
  115. button.innerHTML = '⏳ Getting latest base branch...';
  116. const latestBaseRef = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs/heads/${prInfo.baseBranch}`);
  117. const latestBaseSha = latestBaseRef.object.sha;
  118. debugLog('Latest Base SHA:', latestBaseSha);
  119.  
  120. // Get the comparison between base and head
  121. button.innerHTML = '⏳ Comparing changes...';
  122. const comparison = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/compare/${prDetails.base.sha}...${prDetails.head.sha}`);
  123. debugLog('Comparison:', comparison);
  124.  
  125. // Create new branch name
  126. const timestamp = new Date().getTime();
  127. const newBranchName = `squashed-pr-${prInfo.prNumber}-${timestamp}`;
  128. debugLog('New Branch Name:', newBranchName);
  129.  
  130. // Create new branch from the latest base
  131. button.innerHTML = '⏳ Creating new branch...';
  132. await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs`, 'POST', {
  133. ref: `refs/heads/${newBranchName}`,
  134. sha: latestBaseSha
  135. });
  136.  
  137. // Get the PR commits to form a proper commit message
  138. button.innerHTML = '⏳ Getting PR commits...';
  139. const commits = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}/commits`);
  140. // Create a combined commit message
  141. let commitMessage = `${prInfo.title}\n\n`;
  142. if (prInfo.description) {
  143. commitMessage += `${prInfo.description}\n\n`;
  144. }
  145. commitMessage += `Squashed commits from #${prInfo.prNumber}:\n\n`;
  146. commits.forEach(commit => {
  147. commitMessage += `* ${commit.commit.message.split('\n')[0]}\n`;
  148. });
  149.  
  150. // Get the PR files to apply changes
  151. button.innerHTML = '⏳ Getting PR changes...';
  152. const files = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}/files`);
  153. debugLog(`PR has ${files.length} changed files`);
  154.  
  155. // Get the latest tree from the base branch
  156. const baseTree = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/trees/${latestBaseSha}`);
  157. // Create a new tree with the changes
  158. button.innerHTML = '⏳ Creating new tree with changes...';
  159. // For each changed file, we need to get its content
  160. const treeItems = [];
  161. for (const file of files) {
  162. if (file.status === 'removed') {
  163. // For deleted files, we don't include them in the new tree
  164. treeItems.push({
  165. path: file.filename,
  166. mode: '100644',
  167. type: 'blob',
  168. sha: null // null SHA means delete the file
  169. });
  170. } else {
  171. // For added or modified files, get the content from the head branch
  172. const fileContent = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/contents/${file.filename}?ref=${prDetails.head.ref}`);
  173. // If the file is binary or too large, use its SHA directly
  174. if (fileContent.encoding === 'base64') {
  175. treeItems.push({
  176. path: file.filename,
  177. mode: '100644',
  178. type: 'blob',
  179. content: atob(fileContent.content.replace(/\s/g, ''))
  180. });
  181. } else {
  182. // For other cases (like submodules or very large files), use the SHA
  183. treeItems.push({
  184. path: file.filename,
  185. mode: '100644',
  186. type: 'blob',
  187. sha: fileContent.sha
  188. });
  189. }
  190. }
  191. }
  192. // Create a new tree
  193. const newTree = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/trees`, 'POST', {
  194. base_tree: latestBaseSha,
  195. tree: treeItems
  196. });
  197. // Create the squashed commit
  198. button.innerHTML = '⏳ Creating squashed commit...';
  199. const newCommit = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/commits`, 'POST', {
  200. message: commitMessage,
  201. tree: newTree.sha,
  202. parents: [latestBaseSha]
  203. });
  204. // Update the new branch to point to the squashed commit
  205. button.innerHTML = '⏳ Updating branch...';
  206. await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs/heads/${newBranchName}`, 'PATCH', {
  207. sha: newCommit.sha,
  208. force: true
  209. });
  210.  
  211. // Create new PR with the exact same description
  212. button.innerHTML = '⏳ Creating new PR...';
  213. const newPR = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls`, 'POST', {
  214. title: `${prInfo.title} (Squashed)`,
  215. head: newBranchName,
  216. base: prInfo.baseBranch,
  217. body: `${prInfo.description}\n\n---\n_Squashed version of #${prInfo.prNumber}_`
  218. });
  219.  
  220. // Close original PR
  221. button.innerHTML = '⏳ Closing original PR...';
  222. await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}`, 'PATCH', {
  223. state: 'closed'
  224. });
  225.  
  226. // Redirect to the new PR
  227. window.location.href = newPR.html_url;
  228.  
  229. } catch (error) {
  230. console.error('Failed to squash PR:', error);
  231. debugLog('Error details:', error);
  232. alert(`Failed to squash PR: ${error.message}\nCheck console for details`);
  233. button.disabled = false;
  234. button.innerHTML = '🔄 Squash & Recreate PR';
  235. }
  236. }
  237.  
  238. function addSquashButton() {
  239. if (window.location.href.includes('/pull/')) {
  240. const actionBar = document.querySelector('.gh-header-actions');
  241. if (actionBar && !document.getElementById('squash-button')) {
  242. const squashButton = document.createElement('button');
  243. squashButton.id = 'squash-button';
  244. squashButton.className = 'btn btn-sm btn-primary';
  245. squashButton.innerHTML = '🔄 Squash & Recreate PR';
  246. squashButton.onclick = handleSquash;
  247. actionBar.appendChild(squashButton);
  248. }
  249. }
  250. }
  251.  
  252. // Add button when page loads
  253. addSquashButton();
  254.  
  255. // Add button when navigation occurs
  256. const observer = new MutationObserver(() => {
  257. if (window.location.href.includes('/pull/')) {
  258. addSquashButton();
  259. }
  260. });
  261.  
  262. observer.observe(document.body, { childList: true, subtree: true });
  263. })();