Bitbucket: copy commit reference

Adds a "Copy commit reference" link to every commit page on Bitbucket Cloud and Bitbucket Server.

当前为 2023-12-23 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Bitbucket: copy commit reference
  3. // @namespace https://github.com/rybak/atlassian-tweaks
  4. // @version 7
  5. // @description Adds a "Copy commit reference" link to every commit page on Bitbucket Cloud and Bitbucket Server.
  6. // @license AGPL-3.0-only
  7. // @author Andrei Rybak
  8. // @homepageURL https://github.com/rybak/atlassian-tweaks
  9. // @include https://*bitbucket*/*/commits/*
  10. // @match https://bitbucket.example.com/*/commits/*
  11. // @match https://bitbucket.org/*/commits/*
  12. // @icon https://bitbucket.org/favicon.ico
  13. // @require https://cdn.jsdelivr.net/gh/rybak/userscript-libs@e86c722f2c9cc2a96298c8511028f15c45180185/waitForElement.js
  14. // @require https://cdn.jsdelivr.net/gh/rybak/copy-commit-reference-userscript@c7f2c3b96fd199ceee46de4ba7eb6315659b34e3/copy-commit-reference-lib.js
  15. // @grant none
  16. // ==/UserScript==
  17.  
  18. /*
  19. * Copyright (C) 2023 Andrei Rybak
  20. *
  21. * This program is free software: you can redistribute it and/or modify
  22. * it under the terms of the GNU Affero General Public License as published
  23. * by the Free Software Foundation, version 3.
  24. *
  25. * This program is distributed in the hope that it will be useful,
  26. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  27. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  28. * GNU Affero General Public License for more details.
  29. *
  30. * You should have received a copy of the GNU Affero General Public License
  31. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  32. */
  33.  
  34. /*
  35. * Public commits to test Bitbucket Cloud:
  36. * - Regular commit with Jira issue
  37. * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/1e7277348eb3f7b1dc07b4cc035a6d82943a410f
  38. * - Merge commit with PR mention
  39. * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/7dbe5402633c593021de6bf203278e2c6599c953
  40. * - Merge commit with mentions of Jira issue and PR
  41. * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/19ca4f537e454e15f4e3bf1f88ebc43c0e9c559a
  42. */
  43.  
  44. (function () {
  45. 'use strict';
  46.  
  47. const LOG_PREFIX = '[Bitbucket: copy commit reference]:';
  48. const CONTAINER_ID = "BBCCR_container";
  49.  
  50. function error(...toLog) {
  51. console.error(LOG_PREFIX, ...toLog);
  52. }
  53.  
  54. function warn(...toLog) {
  55. console.warn(LOG_PREFIX, ...toLog);
  56. }
  57.  
  58. function info(...toLog) {
  59. console.info(LOG_PREFIX, ...toLog);
  60. }
  61.  
  62. function debug(...toLog) {
  63. console.debug(LOG_PREFIX, ...toLog);
  64. }
  65.  
  66.  
  67. /*
  68. * Implementation for Bitbucket Cloud.
  69. *
  70. * Example URLs for testing:
  71. * - Regular commit with Jira issue
  72. * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/1e7277348eb3f7b1dc07b4cc035a6d82943a410f
  73. * - Merge commit with PR mention
  74. * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/7dbe5402633c593021de6bf203278e2c6599c953
  75. * - Merge commit with mentions of Jira issue and PR
  76. * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/19ca4f537e454e15f4e3bf1f88ebc43c0e9c559a
  77. *
  78. * Unfortunately, some of the minified/mangled selectors are prone to bit rot.
  79. */
  80. class BitbucketCloud extends GitHosting {
  81. getLoadedSelector() {
  82. return '[data-aui-version]';
  83. }
  84.  
  85. isRecognized() {
  86. // can add more selectors to distinguish from Bitbucket Server, if needed
  87. return document.querySelector('meta[name="bb-view-name"]') != null;
  88. }
  89.  
  90. getTargetSelector() {
  91. /*
  92. * Box with "Jane Doe authored and John Doe committed deadbeef"
  93. * "YYYY-MM-DD"
  94. */
  95. return '.css-tbegx5.e1tw8lnx2';
  96. }
  97.  
  98. getFullHash() {
  99. /*
  100. * "View source" button on the right.
  101. */
  102. const a = document.querySelector('div.css-1oy5iav a.css-1luyhz2');
  103. const href = a.getAttribute('href');
  104. debug("BitbucketCloud:", href);
  105. return href.slice(-41, -1);
  106. }
  107.  
  108. async getDateIso(hash) {
  109. const json = await this.#downloadJson();
  110. return json.date.slice(0, 'YYYY-MM-DD'.length);
  111. }
  112.  
  113. getCommitMessage() {
  114. const commitMsgContainer = document.querySelector('.css-1qa9ryl.e1tw8lnx1+div');
  115. return commitMsgContainer.innerText;
  116. }
  117.  
  118. async convertPlainSubjectToHtml(plainTextSubject) {
  119. /*
  120. * The argument `plainTextSubject` is ignored, because
  121. * we just use JSON from REST API.
  122. */
  123. const json = await this.#downloadJson();
  124. return BitbucketCloud.#firstHtmlParagraph(json.summary.html);
  125. }
  126.  
  127. wrapButtonContainer(container) {
  128. container.style = 'margin-left: 1em;';
  129. return container;
  130. }
  131.  
  132. getButtonTagName() {
  133. return 'button'; // like Bitbucket's buttons "Approve" and "Settings" on a commit's page
  134. }
  135.  
  136. wrapButton(button) {
  137. try {
  138. const icon = document.querySelector('[aria-label="copy commit hash"] svg').cloneNode(true);
  139. icon.classList.add('css-bwxjrz', 'css-snhnyn');
  140. const buttonText = this.getButtonText();
  141. button.replaceChildren(icon, document.createTextNode(` ${buttonText}`));
  142. button.classList.add('css-1luyhz2');
  143. } catch (e) {
  144. warn('BitbucketCloud: cannot find icon of "copy commit hash"');
  145. }
  146. button.title = "Copy commit reference to clipboard";
  147. return button;
  148. }
  149.  
  150. /*
  151. * Adapted from native CSS class `.bqjuWQ`, as of 2023-09-02.
  152. */
  153. createCheckmark() {
  154. const checkmark = super.createCheckmark();
  155. checkmark.style.backgroundColor = 'rgb(23, 43, 77)';
  156. checkmark.style.borderRadius = '3px';
  157. checkmark.style.boxSizing = 'border-box';
  158. checkmark.style.color = 'rgb(255, 255, 255)';
  159. checkmark.style.fontSize = '12px';
  160. checkmark.style.lineHeight = '1.3';
  161. checkmark.style.padding = '2px 6px';
  162. checkmark.style.top = '0'; // this puts the checkmark ~centered w.r.t. the button
  163. return checkmark;
  164. }
  165.  
  166. static #isABitbucketCommitPage() {
  167. const p = document.location.pathname;
  168. if (p.endsWith("commits") || p.endsWith("commits/")) {
  169. info('BitbucketCloud: MutationObserver <title>: this URL does not need the copy button');
  170. return false;
  171. }
  172. if (p.lastIndexOf('/') < 10) {
  173. return false;
  174. }
  175. if (!p.includes('/commits/')) {
  176. return false;
  177. }
  178. // https://stackoverflow.com/a/10671743/1083697
  179. const numberOfSlashes = (p.match(/\//g) || []).length;
  180. if (numberOfSlashes < 4) {
  181. info('BitbucketCloud: This URL does not look like a commit page: not enough slashes');
  182. return false;
  183. }
  184. info('BitbucketCloud: this URL needs a copy button');
  185. return true;
  186. }
  187.  
  188. #currentUrl = document.location.href;
  189.  
  190. #maybePageChanged(eventName, ensureButtonFn) {
  191. info("BitbucketCloud: triggered", eventName);
  192. const maybeNewUrl = document.location.href;
  193. if (maybeNewUrl != this.#currentUrl) {
  194. this.#currentUrl = maybeNewUrl;
  195. info(`BitbucketCloud: ${eventName}: URL has changed:`, this.#currentUrl);
  196. this.#onPageChange();
  197. if (BitbucketCloud.#isABitbucketCommitPage()) {
  198. ensureButtonFn();
  199. }
  200. } else {
  201. info(`BitbucketCloud: ${eventName}: Same URL. Skipping...`);
  202. }
  203. }
  204.  
  205. setUpReadder(ensureButtonFn) {
  206. const observer = new MutationObserver((mutationsList) => {
  207. this.#maybePageChanged('MutationObserver <title>', ensureButtonFn);
  208. });
  209. info('BitbucketCloud: MutationObserver <title>: added');
  210. observer.observe(document.querySelector('head'), { subtree: true, characterData: true, childList: true });
  211. /*
  212. * When user goes back or forward in browser's history.
  213. */
  214. /*
  215. * It seems that there is a bug on bitbucket.org
  216. * with history navigation, so this listener is
  217. * disabled
  218. */
  219. /*
  220. window.addEventListener('popstate', (event) => {
  221. setTimeout(() => {
  222. this.#maybePageChanged('popstate', ensureButtonFn);
  223. }, 100);
  224. });
  225. */
  226. }
  227.  
  228. /*
  229. * Cache of JSON loaded from REST API.
  230. * Caching is needed to avoid multiple REST API requests
  231. * for various methods that need access to the JSON.
  232. */
  233. #commitJson = null;
  234.  
  235. #onPageChange() {
  236. this.#commitJson = null;
  237. }
  238.  
  239. /*
  240. * Downloads JSON object corresponding to the commit via REST API
  241. * of Bitbucket Cloud.
  242. */
  243. async #downloadJson() {
  244. if (this.#commitJson != null) {
  245. return this.#commitJson;
  246. }
  247. try {
  248. // TODO better way of getting projectKey and repositorySlug
  249. const mainSelfLink = document.querySelector('#bitbucket-navigation a');
  250. // slice(1, -1) is needed to cut off slashes
  251. const projectKeyRepoSlug = mainSelfLink.getAttribute('href').slice(1, -1);
  252.  
  253. const commitHash = this.getFullHash();
  254. /*
  255. * REST API reference documentation:
  256. * https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-commit-commit-get
  257. */
  258. const commitRestUrl = `/!api/2.0/repositories/${projectKeyRepoSlug}/commit/${commitHash}?fields=%2B%2A.rendered.%2A`;
  259. info(`BitbucketCloud: Fetching "${commitRestUrl}"...`);
  260. const commitResponse = await fetch(commitRestUrl);
  261. this.#commitJson = await commitResponse.json();
  262. return this.#commitJson;
  263. } catch (e) {
  264. error("BitbucketCloud: cannot fetch commit JSON from REST API", e);
  265. }
  266. }
  267.  
  268. /*
  269. * Extracts first <p> tag out of the provided `html`.
  270. */
  271. static #firstHtmlParagraph(html) {
  272. const OPEN_P_TAG = '<p>';
  273. const CLOSE_P_TAG = '</p>';
  274. const startP = html.indexOf(OPEN_P_TAG);
  275. const endP = html.indexOf(CLOSE_P_TAG);
  276. if (startP < 0 || endP < 0) {
  277. return html;
  278. }
  279. return html.slice(startP + OPEN_P_TAG.length, endP);
  280. }
  281. }
  282.  
  283. /*
  284. * Implementation for Bitbucket Server.
  285. */
  286. class BitbucketServer extends GitHosting {
  287. /**
  288. * This selector is used for {@link isRecognized}. It is fine to
  289. * use a selector specific to commit pages for recognition of
  290. * BitbucketServer, because it does full page reloads when
  291. * clicking to a commit page.
  292. */
  293. static #SHA_LINK_SELECTOR = '.commit-badge-oneline .commit-details .commitid';
  294.  
  295. getLoadedSelector() {
  296. /*
  297. * Same as in BitbucketCloud, but that's fine. Their
  298. * implementations of `isRecognized` are different and
  299. * that will allow the script to distinguish them.
  300. */
  301. return '[data-aui-version]';
  302. }
  303.  
  304. isRecognized() {
  305. return document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR) != null;
  306. }
  307.  
  308. getTargetSelector() {
  309. return '.plugin-section-secondary';
  310. }
  311.  
  312. wrapButtonContainer(container) {
  313. container.classList.add('plugin-item');
  314. return container;
  315. }
  316.  
  317. wrapButton(button) {
  318. const icon = document.createElement('span');
  319. icon.classList.add('aui-icon', 'aui-icon-small', 'aui-iconfont-copy');
  320. const buttonText = this.getButtonText();
  321. button.replaceChildren(icon, document.createTextNode(` ${buttonText}`));
  322. button.title = "Copy commit reference to clipboard";
  323. return button;
  324. }
  325.  
  326. createCheckmark() {
  327. const checkmark = super.createCheckmark();
  328. // positioning
  329. checkmark.style.left = 'unset';
  330. checkmark.style.right = 'calc(100% + 24px + 0.5rem)';
  331. /*
  332. * Layout for CSS selectors for classes .typsy and .tipsy-inner
  333. * are too annoying to replicate here, so just copy-paste the
  334. * look and feel bits.
  335. */
  336. checkmark.style.fontSize = '12px'; // taken from class .tipsy
  337. // the rest -- from .tipsy-inner
  338. checkmark.style.backgroundColor = "#172B4D";
  339. checkmark.style.color = "#FFFFFF";
  340. checkmark.style.padding = "5px 8px 4px 8px";
  341. checkmark.style.borderRadius = "3px";
  342. return checkmark;
  343. }
  344.  
  345. getFullHash() {
  346. const commitAnchor = document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR);
  347. const commitHash = commitAnchor.getAttribute('data-commitid');
  348. return commitHash;
  349. }
  350.  
  351. getDateIso(hash) {
  352. const commitTimeTag = document.querySelector('.commit-badge-oneline .commit-details time');
  353. const dateIso = commitTimeTag.getAttribute('datetime').slice(0, 'YYYY-MM-DD'.length);
  354. return dateIso;
  355. }
  356.  
  357. getCommitMessage(hash) {
  358. const commitAnchor = document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR);
  359. const commitMessage = commitAnchor.getAttribute('data-commit-message');
  360. return commitMessage;
  361. }
  362.  
  363. async convertPlainSubjectToHtml(plainTextSubject, commitHash) {
  364. return await this.#insertPrLinks(await this.#insertJiraLinks(plainTextSubject), commitHash);
  365. }
  366.  
  367. /*
  368. * Extracts Jira issue keys from the Bitbucket UI.
  369. * Works only in Bitbucket Server so far.
  370. * Not needed for Bitbucket Cloud, which uses a separate REST API
  371. * request to provide the HTML content for the clipboard.
  372. */
  373. #getIssueKeys() {
  374. const issuesElem = document.querySelector('.plugin-section-primary .commit-issues-trigger');
  375. if (!issuesElem) {
  376. warn("Cannot find issues element");
  377. return [];
  378. }
  379. const issueKeys = issuesElem.getAttribute('data-issue-keys').split(',');
  380. return issueKeys;
  381. }
  382.  
  383. /*
  384. * Returns the URL to a Jira issue for given key of the Jira issue.
  385. * Uses Bitbucket's REST API for Jira integration (not Jira API).
  386. * A Bitbucket instance may be connected to several Jira instances
  387. * and Bitbucket doesn't know for which Jira instance a particular
  388. * issue mentioned in the commit belongs.
  389. */
  390. async #getIssueUrl(issueKey) {
  391. const projectKey = document.querySelector('[data-projectkey]').getAttribute('data-projectkey');
  392. /*
  393. * This URL for REST API doesn't seem to be documented.
  394. * For example, `jira-integration` isn't mentioned in
  395. * https://docs.atlassian.com/bitbucket-server/rest/7.21.0/bitbucket-jira-rest.html
  396. *
  397. * I've found out about it by checking what Bitbucket
  398. * Server's web UI does when clicking on the Jira
  399. * integration link on a commit's page.
  400. */
  401. const response = await fetch(`${document.location.origin}/rest/jira-integration/latest/issues?issueKey=${issueKey}&entityKey=${projectKey}&fields=url&minimum=10`);
  402. const data = await response.json();
  403. return data[0].url;
  404. }
  405.  
  406. async #insertJiraLinks(text) {
  407. const issueKeys = this.#getIssueKeys();
  408. if (issueKeys.length == 0) {
  409. debug("Found zero issue keys.");
  410. return text;
  411. }
  412. debug("issueKeys:", issueKeys);
  413. for (const issueKey of issueKeys) {
  414. if (text.includes(issueKey)) {
  415. try {
  416. const issueUrl = await this.#getIssueUrl(issueKey);
  417. text = text.replace(issueKey, `<a href="${issueUrl}">${issueKey}</a>`);
  418. } catch (e) {
  419. warn(`Cannot load Jira URL from REST API for issue ${issueKey}`, e);
  420. }
  421. }
  422. }
  423. return text;
  424. }
  425.  
  426. #getProjectKey() {
  427. return document.querySelector('[data-project-key]').getAttribute('data-project-key');
  428. }
  429.  
  430. #getRepositorySlug() {
  431. return document.querySelector('[data-repository-slug]').getAttribute('data-repository-slug');
  432. }
  433.  
  434. /*
  435. * Loads from REST API the pull requests, which involve the given commit.
  436. *
  437. * Tested only on Bitbucket Server.
  438. * Shouldn't be used on Bitbucket Cloud, because of the extra request
  439. * for HTML of the commit message.
  440. */
  441. async #getPullRequests(commitHash) {
  442. const projectKey = this.#getProjectKey();
  443. const repoSlug = this.#getRepositorySlug();
  444. const url = `/rest/api/latest/projects/${projectKey}/repos/${repoSlug}/commits/${commitHash}/pull-requests?start=0&limit=25`;
  445. try {
  446. const response = await fetch(url);
  447. const obj = await response.json();
  448. return obj.values;
  449. } catch (e) {
  450. error(`Cannot getPullRequests url="${url}"`, e);
  451. return [];
  452. }
  453. }
  454.  
  455. /*
  456. * Inserts an HTML anchor to link to the pull requests, which are
  457. * mentioned in the provided `text` in the format that is used by
  458. * Bitbucket's default automatic merge commit messages.
  459. *
  460. * Tested only on Bitbucket Server.
  461. * Shouldn't be used on Bitbucket Cloud, because of the extra request
  462. * for HTML of the commit message.
  463. */
  464. async #insertPrLinks(text, commitHash) {
  465. if (!text.toLowerCase().includes('pull request')) {
  466. return text;
  467. }
  468. try {
  469. const prs = await this.#getPullRequests(commitHash);
  470. /*
  471. * Find the PR ID in the text.
  472. * Assume that there should be only one.
  473. */
  474. const m = new RegExp('pull request [#](\\d+)', 'gmi').exec(text);
  475. if (m.length != 2) {
  476. return text;
  477. }
  478. const linkText = m[0];
  479. const id = parseInt(m[1]);
  480. for (const pr of prs) {
  481. if (pr.id == id) {
  482. const prUrl = pr.links.self[0].href;
  483. text = text.replace(linkText, `<a href="${prUrl}">${linkText}</a>`);
  484. break;
  485. }
  486. }
  487. return text;
  488. } catch (e) {
  489. error("Cannot insert pull request links", e);
  490. return text;
  491. }
  492. }
  493. }
  494.  
  495. CopyCommitReference.runForGitHostings(new BitbucketCloud(), new BitbucketServer());
  496. })();

QingJ © 2025

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