Bitbucket: copy commit reference

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

当前为 2023-10-05 提交的版本,查看 最新版本

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