Bitbucket: copy commit reference

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

安装此脚本?
作者推荐脚本

您可能也喜欢Jira copy summary

安装此脚本
  1. // ==UserScript==
  2. // @name Bitbucket: copy commit reference
  3. // @namespace https://github.com/rybak/atlassian-tweaks
  4. // @version 16
  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@4f71749bc0d302d4ff4a414b0f4a6eddcc6a56ad/copy-commit-reference-lib.js
  15. // @grant none
  16. // ==/UserScript==
  17.  
  18. /*
  19. * Copyright (C) 2023-2024 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 '[data-testid="profileCardTrigger"] + div';
  96. }
  97.  
  98. getFullHash() {
  99. /*
  100. * "View source" button on the right.
  101. */
  102. const a = document.querySelector('#root [data-testid="settingsButton"]')?.parentNode.querySelector('a');
  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('[data-testid="Content"] .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'); // same classes as <span>s inside "Approve" button
  140. const buttonText = this.getButtonText();
  141. button.replaceChildren(icon, document.createTextNode(` ${buttonText}`));
  142. const settingsButton = document.querySelector('#root [data-testid="settingsButton"]');
  143. button.classList.add(settingsButton.classList);
  144. } catch (e) {
  145. warn('BitbucketCloud: cannot find icon of "copy commit hash"');
  146. }
  147. button.title = "Copy commit reference to clipboard";
  148. return button;
  149. }
  150.  
  151. /*
  152. * Adapted from native CSS class `.bqjuWQ`, as of 2023-09-02.
  153. */
  154. createCheckmark() {
  155. const checkmark = super.createCheckmark();
  156. checkmark.style.backgroundColor = 'rgb(23, 43, 77)';
  157. checkmark.style.borderRadius = '3px';
  158. checkmark.style.boxSizing = 'border-box';
  159. checkmark.style.color = 'rgb(255, 255, 255)';
  160. checkmark.style.fontSize = '12px';
  161. checkmark.style.lineHeight = '1.3';
  162. checkmark.style.padding = '2px 6px';
  163. checkmark.style.top = '0'; // this puts the checkmark ~centered w.r.t. the button
  164. return checkmark;
  165. }
  166.  
  167. static #isABitbucketCommitPage() {
  168. const p = document.location.pathname;
  169. if (p.endsWith("commits") || p.endsWith("commits/")) {
  170. info('BitbucketCloud: MutationObserver <title>: this URL does not need the copy button');
  171. return false;
  172. }
  173. if (p.lastIndexOf('/') < 10) {
  174. return false;
  175. }
  176. if (!p.includes('/commits/')) {
  177. return false;
  178. }
  179. // https://stackoverflow.com/a/10671743/1083697
  180. const numberOfSlashes = (p.match(/\//g) || []).length;
  181. if (numberOfSlashes < 4) {
  182. info('BitbucketCloud: This URL does not look like a commit page: not enough slashes');
  183. return false;
  184. }
  185. info('BitbucketCloud: this URL needs a copy button');
  186. return true;
  187. }
  188.  
  189. #currentUrl = document.location.href;
  190.  
  191. #maybePageChanged(eventName, ensureButtonFn) {
  192. info("BitbucketCloud: triggered", eventName);
  193. const maybeNewUrl = document.location.href;
  194. if (maybeNewUrl != this.#currentUrl) {
  195. this.#currentUrl = maybeNewUrl;
  196. info(`BitbucketCloud: ${eventName}: URL has changed:`, this.#currentUrl);
  197. this.#onPageChange();
  198. if (BitbucketCloud.#isABitbucketCommitPage()) {
  199. ensureButtonFn();
  200. }
  201. } else {
  202. info(`BitbucketCloud: ${eventName}: Same URL. Skipping...`);
  203. }
  204. }
  205.  
  206. setUpReadder(ensureButtonFn) {
  207. const observer = new MutationObserver((mutationsList) => {
  208. this.#maybePageChanged('MutationObserver <title>', ensureButtonFn);
  209. });
  210. info('BitbucketCloud: MutationObserver <title>: added');
  211. observer.observe(document.querySelector('head'), { subtree: true, characterData: true, childList: true });
  212. /*
  213. * When user goes back or forward in browser's history.
  214. */
  215. /*
  216. * It seems that there is a bug on bitbucket.org
  217. * with history navigation, so this listener is
  218. * disabled
  219. */
  220. /*
  221. window.addEventListener('popstate', (event) => {
  222. setTimeout(() => {
  223. this.#maybePageChanged('popstate', ensureButtonFn);
  224. }, 100);
  225. });
  226. */
  227. }
  228.  
  229. /*
  230. * Cache of JSON loaded from REST API.
  231. * Caching is needed to avoid multiple REST API requests
  232. * for various methods that need access to the JSON.
  233. */
  234. #commitJson = null;
  235.  
  236. #onPageChange() {
  237. this.#commitJson = null;
  238. }
  239.  
  240. /*
  241. * Downloads JSON object corresponding to the commit via REST API
  242. * of Bitbucket Cloud.
  243. */
  244. async #downloadJson() {
  245. if (this.#commitJson != null) {
  246. return this.#commitJson;
  247. }
  248. try {
  249. // TODO better way of getting projectKey and repositorySlug
  250. const mainSelfLink = document.querySelector('#bitbucket-navigation a');
  251. // slice(1, -1) is needed to cut off slashes
  252. const projectKeyRepoSlug = mainSelfLink.getAttribute('href').slice(1, -1);
  253.  
  254. const commitHash = this.getFullHash();
  255. /*
  256. * REST API reference documentation:
  257. * https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-commit-commit-get
  258. */
  259. const commitRestUrl = `/!api/2.0/repositories/${projectKeyRepoSlug}/commit/${commitHash}?fields=%2B%2A.rendered.%2A`;
  260. info(`BitbucketCloud: Fetching "${commitRestUrl}"...`);
  261. const commitResponse = await fetch(commitRestUrl);
  262. this.#commitJson = await commitResponse.json();
  263. return this.#commitJson;
  264. } catch (e) {
  265. error("BitbucketCloud: cannot fetch commit JSON from REST API", e);
  266. }
  267. }
  268.  
  269. /*
  270. * Extracts first <p> tag out of the provided `html`.
  271. */
  272. static #firstHtmlParagraph(html) {
  273. const OPEN_P_TAG = '<p>';
  274. const CLOSE_P_TAG = '</p>';
  275. const startP = html.indexOf(OPEN_P_TAG);
  276. const endP = html.indexOf(CLOSE_P_TAG);
  277. if (startP < 0 || endP < 0) {
  278. return html;
  279. }
  280. return html.slice(startP + OPEN_P_TAG.length, endP);
  281. }
  282. }
  283.  
  284. /*
  285. * Implementation for Bitbucket Server.
  286. */
  287. class BitbucketServer extends GitHosting {
  288. /**
  289. * This selector is used for {@link isRecognized}. It is fine to
  290. * use a selector specific to commit pages for recognition of
  291. * BitbucketServer, because it does full page reloads when
  292. * clicking to a commit page.
  293. */
  294. static #SHA_LINK_SELECTOR = '.commit-badge-oneline .commit-details .commitid';
  295. static #BITBUCKET_SERVER_8_COMMIT_HASH = '#commit-details-container .commit-hash a';
  296.  
  297. getLoadedSelector() {
  298. /*
  299. * Same as in BitbucketCloud, but that's fine. Their
  300. * implementations of `isRecognized` are different and
  301. * that will allow the script to distinguish them.
  302. */
  303. return '[data-aui-version]';
  304. }
  305.  
  306. isRecognized() {
  307. return document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR) != null ||
  308. document.querySelector(BitbucketServer.#BITBUCKET_SERVER_8_COMMIT_HASH != null) ||
  309. document.querySelector('html.cm-s-stash-default') != null;
  310. }
  311.  
  312. getTargetSelector() {
  313. return '.plugin-section-secondary, .commit-details-summary-panel';
  314. }
  315.  
  316. wrapButtonContainer(container) {
  317. container.classList.add('plugin-item');
  318. return container;
  319. }
  320.  
  321. wrapButton(button) {
  322. const icon = document.createElement('span');
  323. icon.classList.add('aui-icon', 'aui-icon-small', 'aui-iconfont-copy',
  324. 'css-1ujqpe8' // BitbucketServer 8.9.*
  325. );
  326. const buttonText = this.getButtonText();
  327. const buttonTextSpan = document.createElement('span');
  328. buttonTextSpan.classList.add('css-19r5em7'); // BitbucketServer 8.9.*
  329. buttonTextSpan.appendChild(document.createTextNode(` ${buttonText}`));
  330. button.classList.add('css-9bherd'); // BitbucketServer 8.9.*
  331. button.replaceChildren(icon, buttonTextSpan);
  332. button.title = "Copy commit reference to clipboard";
  333. return button;
  334. }
  335.  
  336. createCheckmark() {
  337. const checkmark = super.createCheckmark();
  338. // positioning
  339. checkmark.style.left = 'unset';
  340. checkmark.style.right = 'calc(100% + 24px + 0.5rem)';
  341. /*
  342. * Layout for CSS selectors for classes .typsy and .tipsy-inner
  343. * are too annoying to replicate here, so just copy-paste the
  344. * look and feel bits.
  345. */
  346. checkmark.style.fontSize = '12px'; // taken from class .tipsy
  347. // the rest -- from .tipsy-inner
  348. checkmark.style.backgroundColor = "#172B4D";
  349. checkmark.style.color = "#FFFFFF";
  350. checkmark.style.padding = "5px 8px 4px 8px";
  351. checkmark.style.borderRadius = "3px";
  352. return checkmark;
  353. }
  354.  
  355. getFullHash() {
  356. return this.onAuiVersion(
  357. () => {
  358. const commitAnchor = document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR);
  359. const commitHash = commitAnchor.getAttribute('data-commitid');
  360. return commitHash;
  361. }, () => {
  362. const commitAnchor = document.querySelector(BitbucketServer.#BITBUCKET_SERVER_8_COMMIT_HASH);
  363. return commitAnchor.href.slice(-40, -1);
  364. }
  365. );
  366. }
  367.  
  368. async getDateIso(commitHash) {
  369. return this.#getApiDateIso(commitHash);
  370. }
  371.  
  372. getCommitMessage(hash) {
  373. return this.onAuiVersion(
  374. () => {
  375. const commitAnchor = document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR);
  376. const commitMessage = commitAnchor.getAttribute('data-commit-message');
  377. return commitMessage;
  378. },
  379. () => {
  380. return document.querySelector('#commit-details-container .commit-message').innerText;
  381. }
  382. );
  383. }
  384.  
  385. async convertPlainSubjectToHtml(plainTextSubject, commitHash) {
  386. const escapedHtml = await super.convertPlainSubjectToHtml(plainTextSubject, commitHash);
  387. return await this.#insertPrLinks(await this.#insertJiraLinks(escapedHtml), commitHash);
  388. }
  389.  
  390. /*
  391. * Extracts Jira issue keys from the Bitbucket UI.
  392. * Works only in Bitbucket Server so far.
  393. * Not needed for Bitbucket Cloud, which uses a separate REST API
  394. * request to provide the HTML content for the clipboard.
  395. */
  396. #getIssueKeys() {
  397. const issuesElem = document.querySelector('.plugin-section-primary .commit-issues-trigger');
  398. if (!issuesElem) {
  399. if (!issuesElem) {
  400. info("Newer version of Bitbucket Server with mangled CSS classes. Hold onto your butt.");
  401. const keys = new Set();
  402. document.querySelectorAll('[data-issuekey]').forEach(a => keys.add(a.dataset.issuekey));
  403. const array = Array.from(keys);
  404. if (array.length === 0) {
  405. warn("Cannot find issues elements for Jira integration.");
  406. }
  407. return array;
  408. }
  409. return [];
  410. }
  411. const issueKeys = issuesElem.getAttribute('data-issue-keys').split(',');
  412. return issueKeys;
  413. }
  414.  
  415. /*
  416. * Returns the URL to a Jira issue for given key of the Jira issue.
  417. * Uses Bitbucket's REST API for Jira integration (not Jira API).
  418. * A Bitbucket instance may be connected to several Jira instances
  419. * and Bitbucket doesn't know for which Jira instance a particular
  420. * issue mentioned in the commit belongs.
  421. */
  422. async #getIssueUrl(issueKey) {
  423. const projectKey = document.querySelector('[data-projectkey]').getAttribute('data-projectkey');
  424. /*
  425. * This URL for REST API doesn't seem to be documented.
  426. * For example, `jira-integration` isn't mentioned in
  427. * https://docs.atlassian.com/bitbucket-server/rest/7.21.0/bitbucket-jira-rest.html
  428. *
  429. * I've found out about it by checking what Bitbucket
  430. * Server's web UI does when clicking on the Jira
  431. * integration link on a commit's page.
  432. */
  433. const response = await fetch(`${document.location.origin}/rest/jira-integration/latest/issues?issueKey=${issueKey}&entityKey=${projectKey}&fields=url&minimum=10`);
  434. const data = await response.json();
  435. return data[0].url;
  436. }
  437.  
  438. async #insertJiraLinks(text) {
  439. const issueKeys = this.#getIssueKeys();
  440. if (issueKeys.length == 0) {
  441. debug("Found zero issue keys.");
  442. return text;
  443. }
  444. debug("issueKeys:", issueKeys);
  445. for (const issueKey of issueKeys) {
  446. if (text.includes(issueKey)) {
  447. try {
  448. const issueUrl = await this.#getIssueUrl(issueKey);
  449. text = text.replace(issueKey, `<a href="${issueUrl}">${issueKey}</a>`);
  450. } catch (e) {
  451. warn(`Cannot load Jira URL from REST API for issue ${issueKey}`, e);
  452. }
  453. }
  454. }
  455. return text;
  456. }
  457.  
  458. #getProjectKey() {
  459. return document.querySelector('[data-project-key]').getAttribute('data-project-key');
  460. }
  461.  
  462. #getRepositorySlug() {
  463. return document.querySelector('[data-repository-slug]').getAttribute('data-repository-slug');
  464. }
  465.  
  466. /*
  467. * Loads from REST API the pull requests, which involve the given commit.
  468. *
  469. * Tested only on Bitbucket Server.
  470. * Shouldn't be used on Bitbucket Cloud, because of the extra request
  471. * for HTML of the commit message.
  472. */
  473. async #getPullRequests(commitHash) {
  474. const projectKey = this.#getProjectKey();
  475. const repoSlug = this.#getRepositorySlug();
  476. const url = `/rest/api/latest/projects/${projectKey}/repos/${repoSlug}/commits/${commitHash}/pull-requests?start=0&limit=25`;
  477. try {
  478. const response = await fetch(url);
  479. const obj = await response.json();
  480. return obj.values;
  481. } catch (e) {
  482. error(`Cannot getPullRequests url="${url}"`, e);
  483. return [];
  484. }
  485. }
  486.  
  487. /*
  488. * Inserts an HTML anchor to link to the pull requests, which are
  489. * mentioned in the provided `text` in the format that is used by
  490. * Bitbucket's default automatic merge commit messages.
  491. *
  492. * Tested only on Bitbucket Server.
  493. * Shouldn't be used on Bitbucket Cloud, because of the extra request
  494. * for HTML of the commit message.
  495. */
  496. async #insertPrLinks(text, commitHash) {
  497. if (!text.toLowerCase().includes('pull request')) {
  498. return text;
  499. }
  500. try {
  501. const prs = await this.#getPullRequests(commitHash);
  502. /*
  503. * Find the PR ID in the text.
  504. * Assume that there should be only one.
  505. */
  506. const m = new RegExp('pull request [#](\\d+)', 'gmi').exec(text);
  507. if (m.length != 2) {
  508. return text;
  509. }
  510. const linkText = m[0];
  511. const id = parseInt(m[1]);
  512. for (const pr of prs) {
  513. if (pr.id == id) {
  514. const prUrl = pr.links.self[0].href;
  515. text = text.replace(linkText, `<a href="${prUrl}">${linkText}</a>`);
  516. break;
  517. }
  518. }
  519. return text;
  520. } catch (e) {
  521. error("Cannot insert pull request links", e);
  522. return text;
  523. }
  524. }
  525.  
  526. async #getApiDateIso(commitHash) {
  527. const t = await this.#getApiTimestamp(commitHash);
  528. const d = new Date(t);
  529. return d.toISOString().slice(0, 'YYYY-MM-DD'.length);
  530. }
  531.  
  532. async #getApiTimestamp(commitHash) {
  533. const projectKey = this.#getProjectKey();
  534. const repoSlug = this.#getRepositorySlug();
  535. const url = `/rest/api/latest/projects/${projectKey}/repos/${repoSlug}/commits/${commitHash}`;
  536. try {
  537. const response = await fetch(url);
  538. const obj = await response.json();
  539. return obj.authorTimestamp;
  540. } catch (e) {
  541. error(`Cannot getApiTimestamp url="${url}"`, e);
  542. return NaN;
  543. }
  544. }
  545.  
  546. onAuiVersion(eight, nine) {
  547. if (parseInt(document.body.dataset.auiVersion.split('.')[0]) > 8) {
  548. return nine();
  549. } else {
  550. return eight();
  551. }
  552. }
  553. }
  554.  
  555. CopyCommitReference.runForGitHostings(new BitbucketCloud(), new BitbucketServer());
  556. })();