GitHub: Copy Commit Reference

Adds a "Copy commit reference" link to every commit page.

目前为 2023-08-11 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub: Copy Commit Reference
  3. // @namespace https://github.com/rybak
  4. // @license MIT
  5. // @version 2-alpha
  6. // @description Adds a "Copy commit reference" link to every commit page.
  7. // @author Andrei Rybak
  8. // @include https://*github*/*/commit/*
  9. // @match https://github.example.com/*/commit/*
  10. // @match https://github.com/*/commit/*
  11. // @icon https://github.githubassets.com/favicons/favicon-dark.png
  12. // @grant none
  13. // ==/UserScript==
  14.  
  15. /*
  16. * Copyright (c) 2023 Andrei Rybak
  17. *
  18. * Permission is hereby granted, free of charge, to any person obtaining a copy
  19. * of this software and associated documentation files (the "Software"), to deal
  20. * in the Software without restriction, including without limitation the rights
  21. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  22. * copies of the Software, and to permit persons to whom the Software is
  23. * furnished to do so, subject to the following conditions:
  24. *
  25. * The above copyright notice and this permission notice shall be included in all
  26. * copies or substantial portions of the Software.
  27. *
  28. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  29. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  30. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  31. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  32. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  33. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  34. * SOFTWARE.
  35. */
  36.  
  37. (function() {
  38. 'use strict';
  39.  
  40. const LOG_PREFIX = '[GitHub: copy commit reference]:';
  41. const CONTAINER_ID = "GHCCR_container";
  42. const CHECKMARK_ID = "GHCCR_checkmark";
  43. let inProgress = false;
  44.  
  45. function error(...toLog) {
  46. console.error(LOG_PREFIX, ...toLog);
  47. }
  48.  
  49. function warn(...toLog) {
  50. console.warn(LOG_PREFIX, ...toLog);
  51. }
  52.  
  53. function info(...toLog) {
  54. console.info(LOG_PREFIX, ...toLog);
  55. }
  56.  
  57. function debug(...toLog) {
  58. console.debug(LOG_PREFIX, ...toLog);
  59. }
  60.  
  61. /*
  62. * Extracts the first line of the commit message.
  63. * If the first line is too small, extracts more lines.
  64. */
  65. function commitMessageToSubject(commitMessage) {
  66. const lines = commitMessage.split('\n');
  67. if (lines[0].length > 16) {
  68. /*
  69. * Most common use-case: a normal commit message with
  70. * a normal-ish subject line.
  71. */
  72. return lines[0].trim();
  73. }
  74. /*
  75. * The `if`s below handle weird commit messages I have
  76. * encountered in the wild.
  77. */
  78. if (lines.length < 2) {
  79. return lines[0].trim();
  80. }
  81. if (lines[1].length == 0) {
  82. return lines[0].trim();
  83. }
  84. // sometimes subject is weirdly split across two lines
  85. return lines[0].trim() + " " + lines[1].trim();
  86. }
  87.  
  88. function abbreviateCommitId(commitId) {
  89. return commitId.slice(0, 7)
  90. }
  91.  
  92. /*
  93. * Formats given commit metadata as a commit reference according
  94. * to `git log --format=reference`. See format descriptions at
  95. * https://git-scm.com/docs/git-log#_pretty_formats
  96. */
  97. function plainTextCommitReference(commitId, subject, dateIso) {
  98. debug(`plainTextCommitReference("${commitId}", "${subject}", "${dateIso}")`);
  99. const abbrev = abbreviateCommitId(commitId);
  100. return `${abbrev} (${subject}, ${dateIso})`;
  101. }
  102.  
  103. /*
  104. * Inserts an HTML anchor to link to the pull requests, which are
  105. * mentioned in the provided `text` in the format that is used by
  106. * GitHub's default automatic merge commit messages.
  107. */
  108. async function insertPrLinks(text, commitId) {
  109. if (!text.toLowerCase().includes('pull request')) {
  110. return text;
  111. }
  112. try {
  113. // a hack: just get the existing HTML from the GUI
  114. // the hack probably doesn't work very well with overly long subject lines
  115. // TODO: proper conversion of `text`
  116. return document.querySelector('.commit-title.markdown-title').innerHTML.trim();
  117. } catch (e) {
  118. error("Cannot insert pull request links", e);
  119. return text;
  120. }
  121. }
  122.  
  123. /*
  124. * Renders given commit that has the provided subject line and date
  125. * in reference format as HTML content, which includes a clickable
  126. * link to the commit.
  127. *
  128. * Documentation of formats: https://git-scm.com/docs/git-log#_pretty_formats
  129. */
  130. async function htmlSyntaxLink(commitId, subject, dateIso) {
  131. const url = document.location.href;
  132. const abbrev = abbreviateCommitId(commitId);
  133. let subjectHtml;
  134. subjectHtml = await insertPrLinks(subject, commitId);
  135. debug("subjectHtml", subjectHtml);
  136. const html = `<a href="${url}">${abbrev}</a> (${subjectHtml}, ${dateIso})`;
  137. return html;
  138. }
  139.  
  140. function addLinkToClipboard(event, plainText, html) {
  141. event.stopPropagation();
  142. event.preventDefault();
  143.  
  144. let clipboardData = event.clipboardData || window.clipboardData;
  145. clipboardData.setData('text/plain', plainText);
  146. clipboardData.setData('text/html', html);
  147. }
  148.  
  149. function getApiHostUrl() {
  150. const host = document.location.host;
  151. return `https://api.${host}`;
  152. }
  153.  
  154. function getFullCommitId() {
  155. const path = document.querySelector('a.js-permalink-shortcut').getAttribute('href');
  156. const parts = path.split('/');
  157. if (parts.length < 5) {
  158. throw new Error("Cannot find commit hash in the URL");
  159. }
  160. const commitId = parts[4];
  161. return commitId;
  162. }
  163.  
  164. function getCommitRestApiUrl(commitId) {
  165. // /repos/{owner}/{repo}/commits/{ref}
  166. // e.g. https://api.github.com/repos/rybak/atlassian-tweaks/commits/a76a9a6e993a7a0e48efabdd36f4c893317f1387
  167. // NOTE: plural "commits" in the URL!!!
  168. const apiHostUrl = getApiHostUrl();
  169. const path = document.querySelector('a.js-permalink-shortcut').getAttribute('href');
  170. const parts = path.split('/');
  171. if (parts.length < 5) {
  172. throw new Error("Cannot find commit hash in the URL");
  173. }
  174. const owner = parts[1];
  175. const repo = parts[2];
  176. return `${apiHostUrl}/repos/${owner}/${repo}/commits/${commitId}`;
  177. }
  178.  
  179. function getRestApiOptions() {
  180. const myHeaders = new Headers();
  181. myHeaders.append("Accept", "application/vnd.github+json");
  182. const myInit = {
  183. headers: myHeaders,
  184. };
  185. return myInit;
  186. }
  187.  
  188. /*
  189. * Generates the content and passes it to the clipboard.
  190. *
  191. * Async, because we need to access Jira integration via REST API
  192. * to generate the fancy HTML, with links to Jira.
  193. */
  194. async function copyClickAction(event) {
  195. event.preventDefault();
  196. try {
  197. /*
  198. * Extract metadata about the commit from the UI.
  199. */
  200. let commitJson;
  201. const commitId = getFullCommitId();
  202.  
  203. try {
  204. const commitRestUrl = getCommitRestApiUrl(commitId);
  205. info(`Fetching "${commitRestUrl}"...`);
  206. const commitResponse = await fetch(commitRestUrl, getRestApiOptions());
  207. commitJson = await commitResponse.json();
  208. } catch (e) {
  209. error("Cannot fetch commit JSON from REST API", e);
  210. }
  211. /*
  212. * If loaded successfully, extract particular parts of
  213. * the JSON that we are interested in.
  214. */
  215. const dateIso = commitJson.commit.author.date.slice(0, 'YYYY-MM-DD'.length);
  216. const commitMessage = commitJson.commit.message;
  217. const subject = commitMessageToSubject(commitMessage);
  218.  
  219. const plainText = plainTextCommitReference(commitId, subject, dateIso);
  220. const html = await htmlSyntaxLink(commitId, subject, dateIso);
  221. info("plain text:", plainText);
  222. info("HTML:", html);
  223.  
  224. const handleCopyEvent = e => {
  225. addLinkToClipboard(e, plainText, html);
  226. };
  227. document.addEventListener('copy', handleCopyEvent);
  228. document.execCommand('copy');
  229. document.removeEventListener('copy', handleCopyEvent);
  230. } catch (e) {
  231. error('Could not do the copying', e);
  232. }
  233. }
  234.  
  235. // from https://stackoverflow.com/a/61511955/1083697 by Yong Wang
  236. function waitForElement(selector) {
  237. return new Promise(resolve => {
  238. if (document.querySelector(selector)) {
  239. return resolve(document.querySelector(selector));
  240. }
  241. const observer = new MutationObserver(mutations => {
  242. if (document.querySelector(selector)) {
  243. resolve(document.querySelector(selector));
  244. observer.disconnect();
  245. }
  246. });
  247.  
  248. observer.observe(document.body, {
  249. childList: true,
  250. subtree: true
  251. });
  252. });
  253. }
  254.  
  255. // adapted from https://stackoverflow.com/a/35385518/1083697 by Mark Amery
  256. function htmlToElement(html) {
  257. const template = document.createElement('template');
  258. template.innerHTML = html.trim();
  259. return template.content.firstChild;
  260. }
  261.  
  262. function showCheckmark() {
  263. const checkmark = document.getElementById(CHECKMARK_ID);
  264. checkmark.style.display = 'inline';
  265. }
  266.  
  267. function hideCheckmark() {
  268. const checkmark = document.getElementById(CHECKMARK_ID);
  269. checkmark.style.display = 'none';
  270. }
  271.  
  272. function createCopyLink() {
  273. const onclick = (event) => {
  274. showCheckmark();
  275. copyClickAction(event);
  276. setTimeout(hideCheckmark, 2000);
  277. }
  278.  
  279. const linkText = "Copy commit reference";
  280. const style = 'margin-left: 1em;';
  281. const anchor = htmlToElement(`<a href="#" style="${style}" class="Link--onHover color-fg-muted"></a>`);
  282. const icon = document.querySelector('.octicon-copy').cloneNode(true);
  283. icon.classList.remove('color-fg-muted');
  284. anchor.append(icon);
  285. anchor.append(` ${linkText}`);
  286. anchor.onclick = onclick;
  287. return anchor;
  288. }
  289.  
  290. function createCheckmark() {
  291. const container = document.createElement('span');
  292. container.id = CHECKMARK_ID;
  293. container.style.display = 'none';
  294. container.innerHTML = " ✅ Copied!";
  295. return container;
  296. }
  297.  
  298. function doAddLink() {
  299. waitForElement('.commit.full-commit .commit-meta div.flex-self-start.flex-content-center').then(target => {
  300. debug('target', target);
  301. const container = htmlToElement(`<span id="${CONTAINER_ID}"></span>`);
  302. target.append(container);
  303. const link = createCopyLink();
  304. container.append(' ');
  305. container.appendChild(link);
  306. container.append(createCheckmark());
  307. });
  308. }
  309.  
  310. function removeExistingContainer() {
  311. const container = document.getElementById(CONTAINER_ID);
  312. if (!container) {
  313. return;
  314. }
  315. container.parentNode.removeChild(container);
  316. }
  317.  
  318. function ensureLink() {
  319. if (inProgress) {
  320. return;
  321. }
  322. inProgress = true;
  323. try {
  324. removeExistingContainer();
  325. /*
  326. * Need this tag to have parent for the container.
  327. */
  328. waitForElement('.commit.full-commit .commit-meta').then(loadedBody => {
  329. doAddLink();
  330. if (document.getElementById(CONTAINER_ID) == null) {
  331. ensureLink();
  332. }
  333. });
  334. } catch (e) {
  335. error('Could not create the button', e);
  336. } finally {
  337. inProgress = false;
  338. }
  339. }
  340.  
  341. ensureLink();
  342.  
  343. /*
  344. * Handling of on-the-fly page loading.
  345. *
  346. * - The usual MutationObserver on <title> doesn't work.
  347. * - None of the below event listeners work:
  348. * - https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event
  349. * - https://developer.mozilla.org/en-US/docs/Web/API/Window/hashchange_event
  350. * - https://developer.mozilla.org/en-US/docs/Web/API/Window/load_event
  351. *
  352. * I found 'soft-nav:progress-bar:start' in a call stack in GitHub's own JS,
  353. * and just tried replacing "start" with "end". So far, seems to work fine.
  354. */
  355. document.addEventListener('soft-nav:progress-bar:end', (event) => {
  356. info("progress-bar:end", event);
  357. ensureLink();
  358. });
  359. })();