GitHub: copy commit reference

Adds a "Copy commit reference" button to every commit page on GitHub.

安裝腳本?
作者推薦腳本

您可能也會喜歡 GitHub: PR author avatar as tab icon

安裝腳本
  1. // ==UserScript==
  2. // @name GitHub: copy commit reference
  3. // @namespace https://andrybak.dev
  4. // @license AGPL-3.0-only
  5. // @version 9
  6. // @description Adds a "Copy commit reference" button to every commit page on GitHub.
  7. // @homepageURL https://gf.qytechs.cn/en/scripts/472870-github-copy-commit-reference
  8. // @supportURL https://gf.qytechs.cn/en/scripts/472870-github-copy-commit-reference/feedback
  9. // @author Andrei Rybak
  10. // @match https://github.com/*
  11. // @icon https://github.githubassets.com/favicons/favicon-dark.png
  12. // @require https://cdn.jsdelivr.net/gh/rybak/userscript-libs@e86c722f2c9cc2a96298c8511028f15c45180185/waitForElement.js
  13. // @require https://cdn.jsdelivr.net/gh/rybak/copy-commit-reference-userscript@4f71749bc0d302d4ff4a414b0f4a6eddcc6a56ad/copy-commit-reference-lib.js
  14. // @grant none
  15. // ==/UserScript==
  16.  
  17. /*
  18. * Copyright (C) 2023-2025 Andrei Rybak
  19. *
  20. * This program is free software: you can redistribute it and/or modify
  21. * it under the terms of the GNU Affero General Public License as published
  22. * by the Free Software Foundation, version 3.
  23. *
  24. * This program is distributed in the hope that it will be useful,
  25. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  26. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  27. * GNU Affero General Public License for more details.
  28. *
  29. * You should have received a copy of the GNU Affero General Public License
  30. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  31. */
  32.  
  33. (function () {
  34. 'use strict';
  35.  
  36. const LOG_PREFIX = '[GitHub: copy commit reference]:';
  37.  
  38. function error(...toLog) {
  39. console.error(LOG_PREFIX, ...toLog);
  40. }
  41.  
  42. function warn(...toLog) {
  43. console.warn(LOG_PREFIX, ...toLog);
  44. }
  45.  
  46. function info(...toLog) {
  47. console.info(LOG_PREFIX, ...toLog);
  48. }
  49.  
  50. function debug(...toLog) {
  51. console.debug(LOG_PREFIX, ...toLog);
  52. }
  53.  
  54. /*
  55. * Implementation for GitHub.
  56. * This was tested on https://github.com, but wasn't tested in GitHub Enterprise.
  57. *
  58. * Example URL for testing:
  59. * - Regular commit
  60. * https://github.com/git/git/commit/1f0fc1db8599f87520494ca4f0e3c1b6fabdf997
  61. * - Merge commit with PR mention:
  62. * https://github.com/rybak/atlassian-tweaks/commit/fbeb0e54b64c894d9ba516db3a35c10bf409bfa6
  63. * - Empty commit (no diff, i.e. no changes committed)
  64. * https://github.com/rybak/copy-commit-reference-user-js/commit/234804fac57b39dd0017bc6f63aae1c1ce503d52
  65. */
  66. class GitHub extends GitHosting {
  67. /*
  68. * Mandatory overrides.
  69. */
  70.  
  71. /*
  72. * CSS selector to use to find the element, to which the button
  73. * will be added.
  74. */
  75. getTargetSelector() {
  76. const commitPageSelector = '.CommitHeader-module__commit-message-container--nl1pf > span:first-child';
  77. const prCommitPageSelector = '.commit.full-commit div:first-child';
  78. return `${commitPageSelector}, ${prCommitPageSelector}`;
  79. }
  80.  
  81. getFullHash() {
  82. if (GitHub.#isAPullRequestPage()) {
  83. // commit pages in PRs have full SHA hashes
  84. return document.querySelector('.commit.full-commit.prh-commit .commit-meta .sha.user-select-contain').childNodes[0].textContent;
  85. }
  86. /*
  87. * path example: "/git/git/commit/1f0fc1db8599f87520494ca4f0e3c1b6fabdf997"
  88. */
  89. const path = document.querySelector('.dKoKjn').getAttribute('href');
  90. const parts = path.split('/');
  91. if (parts.length < 5) {
  92. throw new Error("Cannot find commit hash in the URL");
  93. }
  94. return parts[4];
  95. }
  96.  
  97. async getDateIso(hash) {
  98. const commitJson = await this.#downloadJson(hash);
  99. return commitJson.commit.author.date.slice(0, 'YYYY-MM-DD'.length);
  100. }
  101.  
  102. async getCommitMessage() {
  103. const commitJson = await this.#downloadJson();
  104. return commitJson.commit.message;
  105. }
  106.  
  107. /*
  108. * Optional overrides.
  109. */
  110.  
  111. getButtonTagName() {
  112. return 'span';
  113. }
  114.  
  115. wrapButtonContainer(container) {
  116. container.style = 'display: flex; justify-content: right;';
  117. return container;
  118. }
  119.  
  120. /**
  121. * @param {HTMLElement} target
  122. * @param {HTMLElement} buttonContainer
  123. */
  124. addButtonContainerToTarget(target, buttonContainer) {
  125. // top-right corner
  126. if (GitHub.#isAPullRequestPage()) {
  127. // to the left of "< Prev | Next >" buttons (if present)
  128. target.insertBefore(buttonContainer, target.childNodes[1]);
  129. } else {
  130. super.addButtonContainerToTarget(target, buttonContainer);
  131. }
  132. }
  133.  
  134. /**
  135. * Styles adapted from GitHub's native CSS classes ".tooltipped::before"
  136. * and ".tooltipped-s::before".
  137. *
  138. * @returns {HTMLElement}
  139. */
  140. #createTooltipTriangle() {
  141. const triangle = document.createElement('div');
  142. triangle.style.position = 'absolute';
  143. triangle.style.zIndex = '1000001';
  144. triangle.style.top = 'calc(-100% + 15px)';
  145. // aligns the base of triangle with the button's emoji
  146. triangle.style.left = '0.45rem';
  147.  
  148. triangle.style.height = '0';
  149. triangle.style.width = '0';
  150. /*
  151. * borders connect at 45° angle => when only bottom border is colored, it's a trapezoid
  152. * but with width=0, the top edge of trapezoid has length 0, so it's a triangle
  153. */
  154. triangle.style.border = '7px solid transparent';
  155. triangle.style.borderBottomColor = 'var(--bgColor-emphasis, var(--color-neutral-emphasis-plus))';
  156. return triangle;
  157. }
  158.  
  159. createCheckmark() {
  160. const checkmark = super.createCheckmark();
  161. checkmark.style.zIndex = '1000000';
  162. checkmark.style.left = '';
  163. if (GitHub.#isAPullRequestPage()) {
  164. checkmark.style.top = '2.5rem';
  165. checkmark.style.right = '2rem';
  166. } else {
  167. checkmark.style.top = '1rem';
  168. checkmark.style.right = '5rem';
  169. }
  170. checkmark.style.font = 'normal normal 11px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"';
  171. checkmark.style.color = 'var(--fgColor-onEmphasis, var(--color-fg-on-emphasis))';
  172. checkmark.style.background = 'var(--bgColor-emphasis, var(--color-neutral-emphasis-plus))';
  173. checkmark.style.borderRadius = '6px';
  174. checkmark.style.padding = '.5em .75em';
  175. const triangle = this.#createTooltipTriangle();
  176. checkmark.appendChild(triangle);
  177. return checkmark;
  178. }
  179.  
  180. async convertPlainSubjectToHtml(plainTextSubject, commitHash) {
  181. const escapedHtml = await super.convertPlainSubjectToHtml(plainTextSubject, commitHash);
  182. return await GitHub.#insertIssuePrLinks(escapedHtml);
  183. }
  184.  
  185. /*
  186. * Adds CSS classes and a nice icon to mimic other buttons in GitHub UI.
  187. */
  188. wrapButton(button) {
  189. button.classList.add(
  190. 'Button--secondary', // for border and background-color
  191. 'Button', // for inner positioning of the icon
  192. 'btn' // for outer positioning like the button "Browse files"
  193. );
  194. button.style.position = 'absolute';
  195. if (GitHub.#isAPullRequestPage()) {
  196. button.classList.add('Button--small'); // like buttons "< Prev | Next >"
  197. } else {
  198. button.style.top = '-1.2rem';
  199. }
  200. try {
  201. // GitHub's .octicon-copy is present on all pages, even if commit is empty
  202. const icon = document.querySelector('.octicon-copy').cloneNode(true);
  203. button.append(icon);
  204. const buttonText = this.getButtonText();
  205. button.replaceChildren(icon, document.createTextNode(` ${buttonText}`));
  206. } catch (e) {
  207. warn('Github: cannot find .octicon-copy');
  208. }
  209. return button;
  210. }
  211.  
  212. static #isAGitHubCommitPage() {
  213. const p = document.location.pathname;
  214. /*
  215. * Note that `pathname` doesn't include slashes from
  216. * repository's directory structure.
  217. */
  218. const slashIndex = p.lastIndexOf('/');
  219. if (slashIndex <= 7) {
  220. info('GitHub: not enough characters to be a commit page');
  221. return false;
  222. }
  223. const beforeLastSlash = p.slice(slashIndex - 7, slashIndex);
  224. /*
  225. * '/commit' for regular commit pages:
  226. * https://github.com/junit-team/junit5/commit/977c85fc31ad6825b4c68f6c6c972a93356ffe74
  227. * 'commits' for commits in PRs:
  228. * https://github.com/junit-team/junit5/pull/3416/commits/3fad8c6c2a3829e2e329b334cd49b19f179d5f1f
  229. */
  230. if (beforeLastSlash != '/commit' && beforeLastSlash != 'commits' /* on PR pages */) {
  231. info('GitHub: missing "/commit" in the URL. Got: ' + beforeLastSlash);
  232. return false;
  233. }
  234. // https://stackoverflow.com/a/10671743/1083697
  235. const numberOfSlashes = (p.match(/\//g) || []).length;
  236. if (numberOfSlashes < 4) {
  237. info('GitHub: This URL does not look like a commit page: not enough slashes');
  238. return false;
  239. }
  240. info('GitHub: this URL needs a copy button');
  241. return true;
  242. }
  243.  
  244. static #isAPullRequestPage() {
  245. return document.location.pathname.includes('/pull/');
  246. }
  247.  
  248. #maybeEnsureButton(eventName, ensureButtonFn) {
  249. info('GitHub: triggered', eventName);
  250. this.#onPageChange();
  251. if (GitHub.#isAGitHubCommitPage()) {
  252. ensureButtonFn();
  253. }
  254. }
  255.  
  256. /*
  257. * Handling of on-the-fly page loading.
  258. *
  259. * I found 'soft-nav:progress-bar:start' in a call stack in GitHub's
  260. * own JS, and just tried replacing "start" with "end". So far, seems
  261. * to work fine.
  262. */
  263. setUpReadder(ensureButtonFn) {
  264. /*
  265. * When user clicks on another commit, e.g. on the parent commit.
  266. */
  267. document.addEventListener('soft-nav:progress-bar:end', (event) => {
  268. this.#maybeEnsureButton('progress-bar:end', ensureButtonFn);
  269. });
  270. /*
  271. * When user goes back or forward in browser's history.
  272. */
  273. window.addEventListener('popstate', (event) => {
  274. /*
  275. * Delay is needed, because 'popstate' seems to be
  276. * triggered with old DOM.
  277. */
  278. setTimeout(() => {
  279. debug('After timeout:');
  280. this.#maybeEnsureButton('popstate', ensureButtonFn);
  281. }, 100);
  282. });
  283. info('GitHub: added re-adder listeners');
  284. }
  285.  
  286. /*
  287. * Cache of JSON loaded from REST API.
  288. * Caching is needed to avoid multiple REST API requests
  289. * for various methods that need access to the JSON.
  290. */
  291. #commitJson = null;
  292.  
  293. #onPageChange() {
  294. this.#commitJson = null;
  295. }
  296.  
  297. /*
  298. * Downloads JSON object corresponding to the commit via REST API
  299. * of GitHub. Reference documentation:
  300. * https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit
  301. */
  302. async #downloadJson(hash) {
  303. if (this.#commitJson != null) {
  304. return this.#commitJson;
  305. }
  306. try {
  307. const commitRestUrl = GitHub.#getCommitRestApiUrl(hash);
  308. info(`GitHub: Fetching "${commitRestUrl}"...`);
  309. const commitResponse = await fetch(commitRestUrl, GitHub.#getRestApiOptions());
  310. this.#commitJson = await commitResponse.json();
  311. return this.#commitJson;
  312. } catch (e) {
  313. error("GitHub: cannot fetch commit JSON from REST API", e);
  314. return null;
  315. }
  316. }
  317.  
  318. static #getApiHostUrl() {
  319. const host = document.location.host;
  320. return `https://api.${host}`;
  321. }
  322.  
  323. static #getCommitRestApiUrl(hash) {
  324. /*
  325. * Format: /repos/{owner}/{repo}/commits/{ref}
  326. * - NOTE: plural "commits" in the URL!!!
  327. * Examples:
  328. * - https://api.github.com/repos/git/git/commits/1f0fc1db8599f87520494ca4f0e3c1b6fabdf997
  329. * - https://api.github.com/repos/rybak/atlassian-tweaks/commits/a76a9a6e993a7a0e48efabdd36f4c893317f1387
  330. */
  331. const apiHostUrl = GitHub.#getApiHostUrl();
  332. const ownerSlashRepo = document.querySelector('[data-current-repository]').getAttribute('data-current-repository');
  333. return `${apiHostUrl}/repos/${ownerSlashRepo}/commits/${hash}`;
  334. }
  335.  
  336. static #getRestApiOptions() {
  337. const myHeaders = new Headers();
  338. myHeaders.append("Accept", "application/vnd.github+json");
  339. const myInit = {
  340. headers: myHeaders,
  341. };
  342. return myInit;
  343. }
  344.  
  345. /*
  346. * Inserts an HTML anchor to link to issues and pull requests, which are
  347. * mentioned in the provided `text` in the `#<number>` format.
  348. */
  349. static #insertIssuePrLinks(text) {
  350. if (!text.toLowerCase().includes('#')) {
  351. return text;
  352. }
  353. try {
  354. // a hack: just get the existing HTML from the GUI
  355. // the hack probably doesn't work very well with overly long subject lines
  356. // TODO: proper conversion of `text`
  357. // though a shorter version (with ellipsis) might be better for HTML version
  358. return document.querySelector('.CommitHeader-module__commit-message-container--nl1pf > span > div').innerHTML.trim();
  359. } catch (e) {
  360. error("GitHub: cannot insert issue or pull request links", e);
  361. return text;
  362. }
  363. }
  364. }
  365.  
  366. CopyCommitReference.runForGitHostings(new GitHub());
  367. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址