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 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name GitHub PR Squasher
  3. // @namespace https://github.com/balakumardev
  4. // @version 1.0.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. // @icon https://github.githubassets.com/favicons/favicon.svg
  10. // @grant GM_xmlhttpRequest
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @grant GM_registerMenuCommand
  14. // @connect api.github.com
  15. // @supportURL https://github.com/balakumardev/github-pr-squasher/issues
  16. // @homepage https://github.com/balakumardev/github-pr-squasher
  17. // ==/UserScript==
  18.  
  19. /*
  20. ===========================================
  21. GitHub PR Squasher
  22. ===========================================
  23.  
  24. A userscript that adds a "Squash & Recreate PR" button to GitHub pull requests.
  25. It creates a new PR with squashed commits while preserving the original description.
  26.  
  27. Features:
  28. - One-click PR squashing
  29. - Preserves PR description
  30. - Automatically closes original PR and deletes the branch cleaning up
  31. - Secure token storage
  32. - Progress indicators
  33.  
  34. Installation:
  35. 1. Install Tampermonkey or Greasemonkey
  36. 2. Install this script
  37. 3. Set your GitHub token (click Tampermonkey icon → Set GitHub Token)
  38. 4. Refresh GitHub
  39. 5. Look for the "Squash & Recreate PR" button on PR pages
  40.  
  41. GitHub Token Instructions:
  42. 1. Go to GitHub Settings → Developer Settings → Personal Access Tokens → Tokens (classic)
  43. 2. Generate new token (classic)
  44. 3. Select 'repo' permission
  45. 4. Copy token and paste it in the script settings
  46.  
  47. Support: mail@balakumar.dev
  48. */
  49.  
  50.  
  51. (function() {
  52. 'use strict';
  53.  
  54. const DEBUG = true;
  55.  
  56. // Add settings menu to Tampermonkey
  57. GM_registerMenuCommand('Set GitHub Token', async () => {
  58. const token = prompt('Enter your GitHub Personal Access Token (Classic):', GM_getValue('github_token', ''));
  59. if (token !== null) {
  60. if (token.startsWith('ghp_')) {
  61. await GM_setValue('github_token', token);
  62. alert('Token saved! Please refresh the page.');
  63. } else {
  64. alert('Invalid token format. Token should start with "ghp_"');
  65. }
  66. }
  67. });
  68.  
  69. function debugLog(...args) {
  70. if (DEBUG) console.log('[PR Squasher]', ...args);
  71. }
  72.  
  73. async function getGitHubToken() {
  74. const token = GM_getValue('github_token');
  75. if (!token) {
  76. throw new Error('GitHub token not set. Click on the Tampermonkey icon and select "Set GitHub Token"');
  77. }
  78. return token;
  79. }
  80.  
  81. async function githubAPI(endpoint, method = 'GET', body = null) {
  82. debugLog(`API Call: ${method} ${endpoint}`);
  83. if (body) debugLog('Request Body:', body);
  84.  
  85. const token = await getGitHubToken();
  86.  
  87. return new Promise((resolve, reject) => {
  88. GM_xmlhttpRequest({
  89. method: method,
  90. url: `https://api.github.com${endpoint}`,
  91. headers: {
  92. 'Authorization': `Bearer ${token}`,
  93. 'Accept': 'application/vnd.github.v3+json',
  94. 'Content-Type': 'application/json',
  95. },
  96. data: body ? JSON.stringify(body) : null,
  97. onload: function(response) {
  98. debugLog(`Response ${endpoint}:`, {
  99. status: response.status,
  100. statusText: response.statusText,
  101. responseText: response.responseText
  102. });
  103.  
  104. if (response.status >= 200 && response.status < 300 || (method === 'DELETE' && response.status === 404)) {
  105. // Allow 404 for DELETE operations as the resource might already be gone
  106. resolve(response.responseText ? JSON.parse(response.responseText) : {});
  107. } else {
  108. reject(new Error(`GitHub API error: ${response.status} - ${response.responseText}`));
  109. }
  110. },
  111. onerror: function(error) {
  112. debugLog('Request failed:', error);
  113. reject(error);
  114. }
  115. });
  116. });
  117. }
  118.  
  119. async function handleSquash() {
  120. const button = document.getElementById('squash-button');
  121. button.disabled = true;
  122. button.innerHTML = '⏳ Starting...';
  123.  
  124. try {
  125. // Verify token exists
  126. await getGitHubToken();
  127.  
  128. // Step 1: Get basic PR info
  129. const prInfo = {
  130. owner: window.location.pathname.split('/')[1],
  131. repo: window.location.pathname.split('/')[2],
  132. prNumber: window.location.pathname.split('/')[4],
  133. branch: document.querySelector('.head-ref').innerText.trim(),
  134. title: document.querySelector('.js-issue-title').innerText.trim(),
  135. baseBranch: document.querySelector('.base-ref').innerText.trim(),
  136. description: document.querySelector('.comment-body')?.innerText.trim() || ''
  137. };
  138. debugLog('PR Info:', prInfo);
  139.  
  140. // Step 2: Get PR details
  141. button.innerHTML = '⏳ Getting PR details...';
  142. const prDetails = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}`);
  143. debugLog('PR Details:', prDetails);
  144.  
  145. // Step 3: Get the head commit's tree
  146. button.innerHTML = '⏳ Getting tree...';
  147. const headCommit = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/commits/${prDetails.head.sha}`);
  148. debugLog('Head Commit:', headCommit);
  149.  
  150. // Step 4: Create new branch name
  151. const timestamp = new Date().getTime();
  152. const newBranchName = `squashed-pr-${prInfo.prNumber}-${timestamp}`;
  153. debugLog('New Branch Name:', newBranchName);
  154.  
  155. // Step 5: Create new branch from base
  156. button.innerHTML = '⏳ Creating new branch...';
  157. await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs`, 'POST', {
  158. ref: `refs/heads/${newBranchName}`,
  159. sha: prDetails.base.sha
  160. });
  161.  
  162. // Step 6: Create squashed commit
  163. button.innerHTML = '⏳ Creating squashed commit...';
  164. const newCommit = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/commits`, 'POST', {
  165. message: `${prInfo.title}\n\nSquashed commits from #${prInfo.prNumber}`,
  166. tree: headCommit.tree.sha,
  167. parents: [prDetails.base.sha]
  168. });
  169. debugLog('New Commit:', newCommit);
  170.  
  171. // Step 7: Update branch reference
  172. button.innerHTML = '⏳ Updating branch...';
  173. await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs/heads/${newBranchName}`, 'PATCH', {
  174. sha: newCommit.sha,
  175. force: true
  176. });
  177.  
  178. // Step 8: Create new PR
  179. button.innerHTML = '⏳ Creating new PR...';
  180. const newPR = await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls`, 'POST', {
  181. title: `${prInfo.title} (Squashed)`,
  182. head: newBranchName,
  183. base: prInfo.baseBranch,
  184. body: `${prInfo.description}\n\n---\n_Squashed version of #${prInfo.prNumber}_`
  185. });
  186.  
  187. // Step 9: Close original PR
  188. button.innerHTML = '⏳ Cleaning up...';
  189. await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/pulls/${prInfo.prNumber}`, 'PATCH', {
  190. state: 'closed'
  191. });
  192.  
  193. // Step 10: Delete original branch
  194. try {
  195. await githubAPI(`/repos/${prInfo.owner}/${prInfo.repo}/git/refs/heads/${prInfo.branch}`, 'DELETE');
  196. debugLog('Deleted original branch:', prInfo.branch);
  197. } catch (error) {
  198. debugLog('Failed to delete original branch:', error);
  199. // Continue even if branch deletion fails
  200. }
  201.  
  202. // Success! Redirect to new PR
  203. window.location.href = newPR.html_url;
  204.  
  205. } catch (error) {
  206. console.error('Failed to squash PR:', error);
  207. debugLog('Error details:', error);
  208. alert(`Failed to squash PR: ${error.message}\nCheck console for details`);
  209. button.disabled = false;
  210. button.innerHTML = '🔄 Squash & Recreate PR';
  211. }
  212. }
  213.  
  214. function addSquashButton() {
  215. if (window.location.href.includes('/pull/')) {
  216. const actionBar = document.querySelector('.gh-header-actions');
  217. if (actionBar && !document.getElementById('squash-button')) {
  218. const squashButton = document.createElement('button');
  219. squashButton.id = 'squash-button';
  220. squashButton.className = 'btn btn-sm btn-primary';
  221. squashButton.innerHTML = '🔄 Squash & Recreate PR';
  222. squashButton.onclick = handleSquash;
  223. actionBar.appendChild(squashButton);
  224. }
  225. }
  226. }
  227.  
  228. // Add button when page loads
  229. addSquashButton();
  230.  
  231. // Add button when navigation occurs
  232. const observer = new MutationObserver(() => {
  233. if (window.location.href.includes('/pull/')) {
  234. addSquashButton();
  235. }
  236. });
  237.  
  238. observer.observe(document.body, { childList: true, subtree: true });
  239. })();