Bitbucket: copy commit reference

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

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

  1. // ==UserScript==
  2. // @name Bitbucket: copy commit reference
  3. // @namespace https://github.com/rybak/atlassian-tweaks
  4. // @version 2
  5. // @description Adds a "Copy commit reference" link to every commit page.
  6. // @author Andrei Rybak
  7. // @include https://*bitbucket*/*/commits/*
  8. // @match https://bitbucket.example.com/*/commits/*
  9. // @match https://bitbucket.org/*/commits/*
  10. // @icon https://bitbucket.org/favicon.ico
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. /*
  15. * Copyright (c) 2023 Andrei Rybak
  16. *
  17. * Permission is hereby granted, free of charge, to any person obtaining a copy
  18. * of this software and associated documentation files (the "Software"), to deal
  19. * in the Software without restriction, including without limitation the rights
  20. * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  21. * copies of the Software, and to permit persons to whom the Software is
  22. * furnished to do so, subject to the following conditions:
  23. *
  24. * The above copyright notice and this permission notice shall be included in all
  25. * copies or substantial portions of the Software.
  26. *
  27. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  28. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  29. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  30. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  31. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  32. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  33. * SOFTWARE.
  34. */
  35.  
  36. /*
  37. * Public commits to test Bitbucket Cloud:
  38. * - Regular commit with Jira issue
  39. * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/1e7277348eb3f7b1dc07b4cc035a6d82943a410f
  40. * - Merge commit with PR mention
  41. * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/7dbe5402633c593021de6bf203278e2c6599c953
  42. * - Merge commit with mentions of Jira issue and PR
  43. * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/19ca4f537e454e15f4e3bf1f88ebc43c0e9c559a
  44. */
  45.  
  46. (function() {
  47. 'use strict';
  48.  
  49. const LOG_PREFIX = '[Bitbucket: copy commit reference]:';
  50. const CONTAINER_ID = "BBCCR_container";
  51.  
  52. function error(...toLog) {
  53. console.error(LOG_PREFIX, ...toLog);
  54. }
  55.  
  56. function warn(...toLog) {
  57. console.warn(LOG_PREFIX, ...toLog);
  58. }
  59.  
  60. function log(...toLog) {
  61. console.log(LOG_PREFIX, ...toLog);
  62. }
  63.  
  64. function debug(...toLog) {
  65. console.debug(LOG_PREFIX, ...toLog);
  66. }
  67.  
  68. /*
  69. * Detects the kind of Bitbucket, invokes corresponding function:
  70. * `serverFn` or `cloudFn`, and returns result of the invocation.
  71. */
  72. function onVersion(serverFn, cloudFn) {
  73. if (document.querySelector('meta[name="bb-single-page-app"]') == null) {
  74. return serverFn();
  75. }
  76. const b = document.body;
  77. const auiVersion = b.getAttribute('data-aui-version');
  78. if (!auiVersion) {
  79. return cloudFn();
  80. }
  81. if (auiVersion.startsWith('7.')) {
  82. /*
  83. * This is weird, but unlike for Jira Server vs Jira Cloud,
  84. * Bitbucket Cloud's AUI version is smaller than AUI version
  85. * of current-ish Bitbucket Server.
  86. */
  87. return cloudFn();
  88. }
  89. if (auiVersion.startsWith('9.')) {
  90. return serverFn();
  91. }
  92. // TODO more ways of detecting the kind of Bitbucket
  93. cloudFn();
  94. }
  95.  
  96. /*
  97. * Extracts the first line of the commit message.
  98. * If the first line is too small, extracts more lines.
  99. */
  100. function commitMessageToSubject(commitMessage) {
  101. const lines = commitMessage.split('\n');
  102. if (lines[0].length > 16) {
  103. /*
  104. * Most common use-case: a normal commit message with
  105. * a normal-ish subject line.
  106. */
  107. return lines[0].trim();
  108. }
  109. /*
  110. * The `if`s below handle weird commit messages I have
  111. * encountered in the wild.
  112. */
  113. if (lines.length < 2) {
  114. return lines[0].trim();
  115. }
  116. if (lines[1].length == 0) {
  117. return lines[0].trim();
  118. }
  119. // sometimes subject is weirdly split across two lines
  120. return lines[0].trim() + " " + lines[1].trim();
  121. }
  122.  
  123. function abbreviateCommitId(commitId) {
  124. return commitId.slice(0, 7)
  125. }
  126.  
  127. /*
  128. * Formats given commit metadata as a commit reference according
  129. * to `git log --format=reference`. See format descriptions at
  130. * https://git-scm.com/docs/git-log#_pretty_formats
  131. */
  132. function plainTextCommitReference(commitId, subject, dateIso) {
  133. const abbrev = abbreviateCommitId(commitId);
  134. return `${abbrev} (${subject}, ${dateIso})`;
  135. }
  136.  
  137. /*
  138. * Extracts Jira issue keys from the Bitbucket UI.
  139. * Works only in Bitbucket Server so far.
  140. * Not needed for Bitbucket Cloud, which uses a separate REST API
  141. * request to provide the HTML content for the clipboard.
  142. */
  143. function getIssueKeys() {
  144. const issuesElem = document.querySelector('.plugin-section-primary .commit-issues-trigger');
  145. if (!issuesElem) {
  146. return [];
  147. }
  148. const issueKeys = issuesElem.getAttribute('data-issue-keys').split(',');
  149. return issueKeys;
  150. }
  151.  
  152. /*
  153. * Returns the URL to a Jira issue for given key of the Jira issue.
  154. * Uses Bitbucket's REST API for Jira integration (not Jira API).
  155. * A Bitbucket instance may be connected to several Jira instances
  156. * and Bitbucket doesn't know for which Jira instance a particular
  157. * issue mentioned in the commit belongs.
  158. */
  159. async function getIssueUrl(issueKey) {
  160. const projectKey = document.querySelector('[data-projectkey]').getAttribute('data-projectkey');
  161. /*
  162. * This URL for REST API doesn't seem to be documented.
  163. * For example, `jira-integration` isn't mentioned in
  164. * https://docs.atlassian.com/bitbucket-server/rest/7.21.0/bitbucket-jira-rest.html
  165. *
  166. * I've found out about it by checking what Bitbucket
  167. * Server's web UI does when clicking on the Jira
  168. * integration link on a commit's page.
  169. */
  170. const response = await fetch(`${document.location.origin}/rest/jira-integration/latest/issues?issueKey=${issueKey}&entityKey=${projectKey}&fields=url&minimum=10`);
  171. const data = await response.json();
  172. return data[0].url;
  173. }
  174.  
  175. async function insertJiraLinks(text) {
  176. const issueKeys = getIssueKeys();
  177. if (issueKeys.length == 0) {
  178. return text;
  179. }
  180. debug("issueKeys:", issueKeys);
  181. for (const issueKey of issueKeys) {
  182. if (text.includes(issueKey)) {
  183. try {
  184. const issueUrl = await getIssueUrl(issueKey);
  185. text = text.replace(issueKey, `<a href="${issueUrl}">${issueKey}</a>`);
  186. } catch(e) {
  187. warn(`Cannot load Jira URL from REST API for issue ${issueKey}`, e);
  188. }
  189. }
  190. }
  191. return text;
  192. }
  193.  
  194. function getProjectKey() {
  195. return document.querySelector('[data-project-key]').getAttribute('data-project-key');
  196. }
  197.  
  198. function getRepositorySlug() {
  199. return document.querySelector('[data-repository-slug]').getAttribute('data-repository-slug');
  200. }
  201.  
  202. /*
  203. * Loads from REST API the pull requests, which involve the given commit.
  204. *
  205. * Tested only on Bitbucket Server.
  206. * Shouldn't be used on Bitbucket Cloud, because of the extra request
  207. * for HTML of the commit message.
  208. */
  209. async function getPullRequests(commitId) {
  210. const projectKey = getProjectKey();
  211. const repoSlug = getRepositorySlug();
  212. const url = `/rest/api/latest/projects/${projectKey}/repos/${repoSlug}/commits/${commitId}/pull-requests?start=0&limit=25`;
  213. try {
  214. const response = await fetch(url);
  215. const obj = await response.json();
  216. return obj.values;
  217. } catch (e) {
  218. error(`Cannot getPullRequests url="${url}"`, e);
  219. return [];
  220. }
  221. }
  222.  
  223. /*
  224. * Inserts an HTML anchor to link to the pull requests, which are
  225. * mentioned in the provided `text` in the format that is used by
  226. * Bitbucket's default automatic merge commit messages.
  227. *
  228. * Tested only on Bitbucket Server.
  229. * Shouldn't be used on Bitbucket Cloud, because of the extra request
  230. * for HTML of the commit message.
  231. */
  232. async function insertPrLinks(text, commitId) {
  233. if (!text.toLowerCase().includes('pull request')) {
  234. return text;
  235. }
  236. try {
  237. const prs = await getPullRequests(commitId);
  238. /*
  239. * Find the PR ID in the text.
  240. * Assume that there should be only one.
  241. */
  242. const m = new RegExp('pull request [#](\\d+)', 'gmi').exec(text);
  243. if (m.length != 2) {
  244. return text;
  245. }
  246. const linkText = m[0];
  247. const id = parseInt(m[1]);
  248. for (const pr of prs) {
  249. if (pr.id == id) {
  250. const prUrl = pr.links.self[0].href;
  251. text = text.replace(linkText, `<a href="${prUrl}">${linkText}</a>`);
  252. break;
  253. }
  254. }
  255. return text;
  256. } catch (e) {
  257. error("Cannot insert pull request links", e);
  258. return text;
  259. }
  260. }
  261.  
  262. /*
  263. * Extracts first <p> tag out of the provided `html`.
  264. */
  265. function firstHtmlParagraph(html) {
  266. const OPEN_P_TAG = '<p>';
  267. const CLOSE_P_TAG = '</p>';
  268. const startP = html.indexOf(OPEN_P_TAG);
  269. const endP = html.indexOf(CLOSE_P_TAG);
  270. if (startP < 0 || endP < 0) {
  271. return html;
  272. }
  273. return html.slice(startP + OPEN_P_TAG.length, endP);
  274. }
  275.  
  276. /*
  277. * Renders given commit that has the provided subject line and date
  278. * in reference format as HTML content, which includes clickable
  279. * links to commits, pull requests, and Jira issues.
  280. *
  281. * Parameter `htmlSubject`:
  282. * Pre-rendered HTML of the subject line of the commit. Optional.
  283. *
  284. * Documentation of formats: https://git-scm.com/docs/git-log#_pretty_formats
  285. */
  286. async function htmlSyntaxLink(commitId, subject, dateIso, htmlSubject) {
  287. const url = document.location.href;
  288. const abbrev = abbreviateCommitId(commitId);
  289. let subjectHtml;
  290. if (htmlSubject && htmlSubject.length > 0) {
  291. subjectHtml = htmlSubject;
  292. } else {
  293. subjectHtml = await insertPrLinks(await insertJiraLinks(subject), commitId);
  294. }
  295. debug("subjectHtml", subjectHtml);
  296. const html = `<a href="${url}">${abbrev}</a> (${subjectHtml}, ${dateIso})`;
  297. return html;
  298. }
  299.  
  300. function addLinkToClipboard(event, plainText, html) {
  301. event.stopPropagation();
  302. event.preventDefault();
  303.  
  304. let clipboardData = event.clipboardData || window.clipboardData;
  305. clipboardData.setData('text/plain', plainText);
  306. clipboardData.setData('text/html', html);
  307. }
  308.  
  309. /*
  310. * Generates the content and passes it to the clipboard.
  311. *
  312. * Async, because we need to access Jira integration via REST API
  313. * to generate the fancy HTML, with links to Jira.
  314. */
  315. async function copyClickAction(event) {
  316. event.preventDefault();
  317. try {
  318. /*
  319. * Extract metadata about the commit from the UI.
  320. */
  321. let commitId, commitMessage, dateIso;
  322. [commitId, commitMessage, dateIso] = onVersion(
  323. () => {
  324. const commitAnchor = document.querySelector('.commit-badge-oneline .commit-details .commitid');
  325. const commitTimeTag = document.querySelector('.commit-badge-oneline .commit-details time');
  326. const commitMessage = commitAnchor.getAttribute('data-commit-message');
  327. const dateIso = commitTimeTag.getAttribute('datetime').slice(0, 'YYYY-MM-DD'.length);
  328. const commitId = commitAnchor.getAttribute('data-commitid');
  329. return [commitId, commitMessage, dateIso];
  330. },
  331. () => {
  332. const commitIdTag = document.querySelector('.css-tbegx5.e1tw8lnx2 strong+strong');
  333. let dateStr;
  334. try {
  335. const commitTimeTag = document.querySelector('.css-tbegx5.e1tw8lnx2 time');
  336. dateStr = commitTimeTag.getAttribute('datetime').slice(0, 'YYYY-MM-DD'.length);
  337. } catch (e) {
  338. /*
  339. * When a commit is recent, Bitbucket Cloud shows a human-readable string
  340. * such as "4 days ago" or "19 minutes ago". This string is localized,
  341. * and the `title` attribute of the corresponding HTML tag is also localized.
  342. * There is no ISO 8601 timestamp easily available.
  343. */
  344. warn("No time tag :-(", e);
  345. dateStr = null;
  346. }
  347.  
  348. const commitMsgContainer = document.querySelector('.css-1qa9ryl.e1tw8lnx1+div');
  349. return [
  350. commitIdTag.innerText,
  351. commitMsgContainer.innerText,
  352. dateStr /* can't extract ISO date in Bitbucket Cloud from UI in _all_ cases */
  353. ];
  354. }
  355. );
  356. /*
  357. * Load pre-rendered HTML.
  358. */
  359. let htmlSubject;
  360. await onVersion(
  361. () => {
  362. /* Bitbucket Server doesn't need additional requests.
  363. * Just initialize `htmlSubject` to an empty string for
  364. * function `htmlSyntaxLink` down the line. */
  365. htmlSubject = "";
  366. },
  367. async () => {
  368. try {
  369. // TODO better way of getting projectKey and repositorySlug
  370. const mainSelfLink = document.querySelector('#bitbucket-navigation a');
  371. // slice(1, -1) is needed to cut off slashes
  372. const projectKeyRepoSlug = mainSelfLink.getAttribute('href').slice(1, -1);
  373. const commitRestUrl = `/!api/2.0/repositories/${projectKeyRepoSlug}/commit/${commitId}?fields=%2B%2A.rendered.%2A`;
  374. log(`Fetching "${commitRestUrl}"...`);
  375. const commitResponse = await fetch(commitRestUrl);
  376. const commitJson = await commitResponse.json();
  377. /*
  378. * If loaded successfully, extract particular parts of
  379. * the JSON that we are interested in.
  380. */
  381. dateIso = commitJson.date.slice(0, 'YYYY-MM-DD'.length);
  382. htmlSubject = firstHtmlParagraph(commitJson.summary.html);
  383. } catch (e) {
  384. error("Cannot fetch commit JSON from REST API", e);
  385. }
  386. }
  387. );
  388.  
  389. const subject = commitMessageToSubject(commitMessage);
  390.  
  391. const plainText = plainTextCommitReference(commitId, subject, dateIso);
  392. const html = await htmlSyntaxLink(commitId, subject, dateIso, htmlSubject);
  393. log("plain text:", plainText);
  394. log("HTML:", html);
  395.  
  396. const handleCopyEvent = e => {
  397. addLinkToClipboard(e, plainText, html);
  398. };
  399. document.addEventListener('copy', handleCopyEvent);
  400. document.execCommand('copy');
  401. document.removeEventListener('copy', handleCopyEvent);
  402. } catch (e) {
  403. error('Could not do the copying', e);
  404. }
  405. }
  406.  
  407. // from https://stackoverflow.com/a/61511955/1083697 by Yong Wang
  408. function waitForElement(selector) {
  409. return new Promise(resolve => {
  410. if (document.querySelector(selector)) {
  411. return resolve(document.querySelector(selector));
  412. }
  413. const observer = new MutationObserver(mutations => {
  414. if (document.querySelector(selector)) {
  415. resolve(document.querySelector(selector));
  416. observer.disconnect();
  417. }
  418. });
  419.  
  420. observer.observe(document.body, {
  421. childList: true,
  422. subtree: true
  423. });
  424. });
  425. }
  426.  
  427. // adapted from https://stackoverflow.com/a/35385518/1083697 by Mark Amery
  428. function htmlToElement(html) {
  429. const template = document.createElement('template');
  430. template.innerHTML = html.trim();
  431. return template.content.firstChild;
  432. }
  433.  
  434. function copyLink() {
  435. const onclick = (event) => copyClickAction(event);
  436.  
  437. const linkText = "Copy commit reference";
  438. const style = 'margin-left: 1em;';
  439. const anchor = htmlToElement(`<a href="#" style="${style}">${linkText}</a>`);
  440. anchor.onclick = onclick;
  441. return anchor;
  442. }
  443.  
  444. function doAddLink() {
  445. onVersion(
  446. () => waitForElement('.commit-details'),
  447. () => waitForElement('.css-tbegx5.e1tw8lnx2')
  448. ).then(target => {
  449. debug('target', target);
  450. const container = htmlToElement(`<span id="${CONTAINER_ID}"></span>`);
  451. target.append(container);
  452. const link = copyLink();
  453. container.append(' ');
  454. container.appendChild(link);
  455. });
  456. }
  457.  
  458. function removeExistingContainer() {
  459. const container = document.getElementById(CONTAINER_ID);
  460. if (!container) {
  461. return;
  462. }
  463. container.parentNode.removeChild(container);
  464. }
  465.  
  466. function ensureLink() {
  467. try {
  468. /*
  469. * Need this attribute to detect the kind of Bitbucket: Server or Cloud.
  470. */
  471. waitForElement('[data-aui-version]')
  472. .then(loadedBody => doAddLink());
  473. } catch (e) {
  474. error('Could not create the button', e);
  475. }
  476. }
  477.  
  478. ensureLink();
  479.  
  480. /*
  481. * Clicking on a commit link on Bitbucket Cloud doesn't trigger a page load
  482. * (sometimes, at least). To cover such cases, we need to automatically
  483. * detect that the commit in the URL has changed.
  484. *
  485. * For whatever reason listener for popstate events doesn't work to
  486. * detect a change in the URL.
  487. * https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event
  488. *
  489. * As a workaround, observe the changes in the <title> tag, since commits
  490. * will have different <title>s.
  491. */
  492. let currentUrl = document.location.href;
  493. const observer = new MutationObserver((mutationsList) => {
  494. const maybeNewUrl = document.location.href;
  495. log('Mutation to', maybeNewUrl);
  496. if (maybeNewUrl != currentUrl) {
  497. currentUrl = maybeNewUrl;
  498. log('MutationObserver: URL has changed:', currentUrl);
  499. ensureLink();
  500. }
  501. });
  502. observer.observe(document.querySelector('title'), { subtree: true, characterData: true, childList: true });
  503. log('Added MutationObserver');
  504. })();