- // ==UserScript==
- // @name Bitbucket: copy commit reference
- // @namespace https://github.com/rybak/atlassian-tweaks
- // @version 16
- // @description Adds a "Copy commit reference" link to every commit page on Bitbucket Cloud and Bitbucket Server.
- // @license AGPL-3.0-only
- // @author Andrei Rybak
- // @homepageURL https://github.com/rybak/atlassian-tweaks
- // @include https://*bitbucket*/*/commits/*
- // @match https://bitbucket.example.com/*/commits/*
- // @match https://bitbucket.org/*/commits/*
- // @icon https://bitbucket.org/favicon.ico
- // @require https://cdn.jsdelivr.net/gh/rybak/userscript-libs@e86c722f2c9cc2a96298c8511028f15c45180185/waitForElement.js
- // @require https://cdn.jsdelivr.net/gh/rybak/copy-commit-reference-userscript@4f71749bc0d302d4ff4a414b0f4a6eddcc6a56ad/copy-commit-reference-lib.js
- // @grant none
- // ==/UserScript==
-
- /*
- * Copyright (C) 2023-2024 Andrei Rybak
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, version 3.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
- /*
- * Public commits to test Bitbucket Cloud:
- * - Regular commit with Jira issue
- * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/1e7277348eb3f7b1dc07b4cc035a6d82943a410f
- * - Merge commit with PR mention
- * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/7dbe5402633c593021de6bf203278e2c6599c953
- * - Merge commit with mentions of Jira issue and PR
- * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/19ca4f537e454e15f4e3bf1f88ebc43c0e9c559a
- */
-
- (function () {
- 'use strict';
-
- const LOG_PREFIX = '[Bitbucket: copy commit reference]:';
- const CONTAINER_ID = "BBCCR_container";
-
- function error(...toLog) {
- console.error(LOG_PREFIX, ...toLog);
- }
-
- function warn(...toLog) {
- console.warn(LOG_PREFIX, ...toLog);
- }
-
- function info(...toLog) {
- console.info(LOG_PREFIX, ...toLog);
- }
-
- function debug(...toLog) {
- console.debug(LOG_PREFIX, ...toLog);
- }
-
-
- /*
- * Implementation for Bitbucket Cloud.
- *
- * Example URLs for testing:
- * - Regular commit with Jira issue
- * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/1e7277348eb3f7b1dc07b4cc035a6d82943a410f
- * - Merge commit with PR mention
- * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/7dbe5402633c593021de6bf203278e2c6599c953
- * - Merge commit with mentions of Jira issue and PR
- * https://bitbucket.org/andreyrybak/atlassian-tweaks/commits/19ca4f537e454e15f4e3bf1f88ebc43c0e9c559a
- *
- * Unfortunately, some of the minified/mangled selectors are prone to bit rot.
- */
- class BitbucketCloud extends GitHosting {
- getLoadedSelector() {
- return '[data-aui-version]';
- }
-
- isRecognized() {
- // can add more selectors to distinguish from Bitbucket Server, if needed
- return document.querySelector('meta[name="bb-view-name"]') != null;
- }
-
- getTargetSelector() {
- /*
- * Box with "Jane Doe authored and John Doe committed deadbeef"
- * "YYYY-MM-DD"
- */
- return '[data-testid="profileCardTrigger"] + div';
- }
-
- getFullHash() {
- /*
- * "View source" button on the right.
- */
- const a = document.querySelector('#root [data-testid="settingsButton"]')?.parentNode.querySelector('a');
- const href = a.getAttribute('href');
- debug("BitbucketCloud:", href);
- return href.slice(-41, -1);
- }
-
- async getDateIso(hash) {
- const json = await this.#downloadJson();
- return json.date.slice(0, 'YYYY-MM-DD'.length);
- }
-
- getCommitMessage() {
- const commitMsgContainer = document.querySelector('[data-testid="Content"] .e1tw8lnx1+div');
- return commitMsgContainer.innerText;
- }
-
- async convertPlainSubjectToHtml(plainTextSubject) {
- /*
- * The argument `plainTextSubject` is ignored, because
- * we just use JSON from REST API.
- */
- const json = await this.#downloadJson();
- return BitbucketCloud.#firstHtmlParagraph(json.summary.html);
- }
-
- wrapButtonContainer(container) {
- container.style = 'margin-left: 1em;';
- return container;
- }
-
- getButtonTagName() {
- return 'button'; // like Bitbucket's buttons "Approve" and "Settings" on a commit's page
- }
-
- wrapButton(button) {
- try {
- const icon = document.querySelector('[aria-label="copy commit hash"] svg').cloneNode(true);
- icon.classList.add('css-bwxjrz', 'css-snhnyn'); // same classes as <span>s inside "Approve" button
- const buttonText = this.getButtonText();
- button.replaceChildren(icon, document.createTextNode(` ${buttonText}`));
- const settingsButton = document.querySelector('#root [data-testid="settingsButton"]');
- button.classList.add(settingsButton.classList);
- } catch (e) {
- warn('BitbucketCloud: cannot find icon of "copy commit hash"');
- }
- button.title = "Copy commit reference to clipboard";
- return button;
- }
-
- /*
- * Adapted from native CSS class `.bqjuWQ`, as of 2023-09-02.
- */
- createCheckmark() {
- const checkmark = super.createCheckmark();
- checkmark.style.backgroundColor = 'rgb(23, 43, 77)';
- checkmark.style.borderRadius = '3px';
- checkmark.style.boxSizing = 'border-box';
- checkmark.style.color = 'rgb(255, 255, 255)';
- checkmark.style.fontSize = '12px';
- checkmark.style.lineHeight = '1.3';
- checkmark.style.padding = '2px 6px';
- checkmark.style.top = '0'; // this puts the checkmark ~centered w.r.t. the button
- return checkmark;
- }
-
- static #isABitbucketCommitPage() {
- const p = document.location.pathname;
- if (p.endsWith("commits") || p.endsWith("commits/")) {
- info('BitbucketCloud: MutationObserver <title>: this URL does not need the copy button');
- return false;
- }
- if (p.lastIndexOf('/') < 10) {
- return false;
- }
- if (!p.includes('/commits/')) {
- return false;
- }
- // https://stackoverflow.com/a/10671743/1083697
- const numberOfSlashes = (p.match(/\//g) || []).length;
- if (numberOfSlashes < 4) {
- info('BitbucketCloud: This URL does not look like a commit page: not enough slashes');
- return false;
- }
- info('BitbucketCloud: this URL needs a copy button');
- return true;
- }
-
- #currentUrl = document.location.href;
-
- #maybePageChanged(eventName, ensureButtonFn) {
- info("BitbucketCloud: triggered", eventName);
- const maybeNewUrl = document.location.href;
- if (maybeNewUrl != this.#currentUrl) {
- this.#currentUrl = maybeNewUrl;
- info(`BitbucketCloud: ${eventName}: URL has changed:`, this.#currentUrl);
- this.#onPageChange();
- if (BitbucketCloud.#isABitbucketCommitPage()) {
- ensureButtonFn();
- }
- } else {
- info(`BitbucketCloud: ${eventName}: Same URL. Skipping...`);
- }
- }
-
- setUpReadder(ensureButtonFn) {
- const observer = new MutationObserver((mutationsList) => {
- this.#maybePageChanged('MutationObserver <title>', ensureButtonFn);
- });
- info('BitbucketCloud: MutationObserver <title>: added');
- observer.observe(document.querySelector('head'), { subtree: true, characterData: true, childList: true });
- /*
- * When user goes back or forward in browser's history.
- */
- /*
- * It seems that there is a bug on bitbucket.org
- * with history navigation, so this listener is
- * disabled
- */
- /*
- window.addEventListener('popstate', (event) => {
- setTimeout(() => {
- this.#maybePageChanged('popstate', ensureButtonFn);
- }, 100);
- });
- */
- }
-
- /*
- * Cache of JSON loaded from REST API.
- * Caching is needed to avoid multiple REST API requests
- * for various methods that need access to the JSON.
- */
- #commitJson = null;
-
- #onPageChange() {
- this.#commitJson = null;
- }
-
- /*
- * Downloads JSON object corresponding to the commit via REST API
- * of Bitbucket Cloud.
- */
- async #downloadJson() {
- if (this.#commitJson != null) {
- return this.#commitJson;
- }
- try {
- // TODO better way of getting projectKey and repositorySlug
- const mainSelfLink = document.querySelector('#bitbucket-navigation a');
- // slice(1, -1) is needed to cut off slashes
- const projectKeyRepoSlug = mainSelfLink.getAttribute('href').slice(1, -1);
-
- const commitHash = this.getFullHash();
- /*
- * REST API reference documentation:
- * https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-commit-commit-get
- */
- const commitRestUrl = `/!api/2.0/repositories/${projectKeyRepoSlug}/commit/${commitHash}?fields=%2B%2A.rendered.%2A`;
- info(`BitbucketCloud: Fetching "${commitRestUrl}"...`);
- const commitResponse = await fetch(commitRestUrl);
- this.#commitJson = await commitResponse.json();
- return this.#commitJson;
- } catch (e) {
- error("BitbucketCloud: cannot fetch commit JSON from REST API", e);
- }
- }
-
- /*
- * Extracts first <p> tag out of the provided `html`.
- */
- static #firstHtmlParagraph(html) {
- const OPEN_P_TAG = '<p>';
- const CLOSE_P_TAG = '</p>';
- const startP = html.indexOf(OPEN_P_TAG);
- const endP = html.indexOf(CLOSE_P_TAG);
- if (startP < 0 || endP < 0) {
- return html;
- }
- return html.slice(startP + OPEN_P_TAG.length, endP);
- }
- }
-
- /*
- * Implementation for Bitbucket Server.
- */
- class BitbucketServer extends GitHosting {
- /**
- * This selector is used for {@link isRecognized}. It is fine to
- * use a selector specific to commit pages for recognition of
- * BitbucketServer, because it does full page reloads when
- * clicking to a commit page.
- */
- static #SHA_LINK_SELECTOR = '.commit-badge-oneline .commit-details .commitid';
- static #BITBUCKET_SERVER_8_COMMIT_HASH = '#commit-details-container .commit-hash a';
-
- getLoadedSelector() {
- /*
- * Same as in BitbucketCloud, but that's fine. Their
- * implementations of `isRecognized` are different and
- * that will allow the script to distinguish them.
- */
- return '[data-aui-version]';
- }
-
- isRecognized() {
- return document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR) != null ||
- document.querySelector(BitbucketServer.#BITBUCKET_SERVER_8_COMMIT_HASH != null) ||
- document.querySelector('html.cm-s-stash-default') != null;
- }
-
- getTargetSelector() {
- return '.plugin-section-secondary, .commit-details-summary-panel';
- }
-
- wrapButtonContainer(container) {
- container.classList.add('plugin-item');
- return container;
- }
-
- wrapButton(button) {
- const icon = document.createElement('span');
- icon.classList.add('aui-icon', 'aui-icon-small', 'aui-iconfont-copy',
- 'css-1ujqpe8' // BitbucketServer 8.9.*
- );
- const buttonText = this.getButtonText();
- const buttonTextSpan = document.createElement('span');
- buttonTextSpan.classList.add('css-19r5em7'); // BitbucketServer 8.9.*
- buttonTextSpan.appendChild(document.createTextNode(` ${buttonText}`));
- button.classList.add('css-9bherd'); // BitbucketServer 8.9.*
- button.replaceChildren(icon, buttonTextSpan);
- button.title = "Copy commit reference to clipboard";
- return button;
- }
-
- createCheckmark() {
- const checkmark = super.createCheckmark();
- // positioning
- checkmark.style.left = 'unset';
- checkmark.style.right = 'calc(100% + 24px + 0.5rem)';
- /*
- * Layout for CSS selectors for classes .typsy and .tipsy-inner
- * are too annoying to replicate here, so just copy-paste the
- * look and feel bits.
- */
- checkmark.style.fontSize = '12px'; // taken from class .tipsy
- // the rest -- from .tipsy-inner
- checkmark.style.backgroundColor = "#172B4D";
- checkmark.style.color = "#FFFFFF";
- checkmark.style.padding = "5px 8px 4px 8px";
- checkmark.style.borderRadius = "3px";
- return checkmark;
- }
-
- getFullHash() {
- return this.onAuiVersion(
- () => {
- const commitAnchor = document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR);
- const commitHash = commitAnchor.getAttribute('data-commitid');
- return commitHash;
- }, () => {
- const commitAnchor = document.querySelector(BitbucketServer.#BITBUCKET_SERVER_8_COMMIT_HASH);
- return commitAnchor.href.slice(-40, -1);
- }
- );
- }
-
- async getDateIso(commitHash) {
- return this.#getApiDateIso(commitHash);
- }
-
- getCommitMessage(hash) {
- return this.onAuiVersion(
- () => {
- const commitAnchor = document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR);
- const commitMessage = commitAnchor.getAttribute('data-commit-message');
- return commitMessage;
- },
- () => {
- return document.querySelector('#commit-details-container .commit-message').innerText;
- }
- );
- }
-
- async convertPlainSubjectToHtml(plainTextSubject, commitHash) {
- const escapedHtml = await super.convertPlainSubjectToHtml(plainTextSubject, commitHash);
- return await this.#insertPrLinks(await this.#insertJiraLinks(escapedHtml), commitHash);
- }
-
- /*
- * Extracts Jira issue keys from the Bitbucket UI.
- * Works only in Bitbucket Server so far.
- * Not needed for Bitbucket Cloud, which uses a separate REST API
- * request to provide the HTML content for the clipboard.
- */
- #getIssueKeys() {
- const issuesElem = document.querySelector('.plugin-section-primary .commit-issues-trigger');
- if (!issuesElem) {
- if (!issuesElem) {
- info("Newer version of Bitbucket Server with mangled CSS classes. Hold onto your butt.");
- const keys = new Set();
- document.querySelectorAll('[data-issuekey]').forEach(a => keys.add(a.dataset.issuekey));
- const array = Array.from(keys);
- if (array.length === 0) {
- warn("Cannot find issues elements for Jira integration.");
- }
- return array;
- }
- return [];
- }
- const issueKeys = issuesElem.getAttribute('data-issue-keys').split(',');
- return issueKeys;
- }
-
- /*
- * Returns the URL to a Jira issue for given key of the Jira issue.
- * Uses Bitbucket's REST API for Jira integration (not Jira API).
- * A Bitbucket instance may be connected to several Jira instances
- * and Bitbucket doesn't know for which Jira instance a particular
- * issue mentioned in the commit belongs.
- */
- async #getIssueUrl(issueKey) {
- const projectKey = document.querySelector('[data-projectkey]').getAttribute('data-projectkey');
- /*
- * This URL for REST API doesn't seem to be documented.
- * For example, `jira-integration` isn't mentioned in
- * https://docs.atlassian.com/bitbucket-server/rest/7.21.0/bitbucket-jira-rest.html
- *
- * I've found out about it by checking what Bitbucket
- * Server's web UI does when clicking on the Jira
- * integration link on a commit's page.
- */
- const response = await fetch(`${document.location.origin}/rest/jira-integration/latest/issues?issueKey=${issueKey}&entityKey=${projectKey}&fields=url&minimum=10`);
- const data = await response.json();
- return data[0].url;
- }
-
- async #insertJiraLinks(text) {
- const issueKeys = this.#getIssueKeys();
- if (issueKeys.length == 0) {
- debug("Found zero issue keys.");
- return text;
- }
- debug("issueKeys:", issueKeys);
- for (const issueKey of issueKeys) {
- if (text.includes(issueKey)) {
- try {
- const issueUrl = await this.#getIssueUrl(issueKey);
- text = text.replace(issueKey, `<a href="${issueUrl}">${issueKey}</a>`);
- } catch (e) {
- warn(`Cannot load Jira URL from REST API for issue ${issueKey}`, e);
- }
- }
- }
- return text;
- }
-
- #getProjectKey() {
- return document.querySelector('[data-project-key]').getAttribute('data-project-key');
- }
-
- #getRepositorySlug() {
- return document.querySelector('[data-repository-slug]').getAttribute('data-repository-slug');
- }
-
- /*
- * Loads from REST API the pull requests, which involve the given commit.
- *
- * Tested only on Bitbucket Server.
- * Shouldn't be used on Bitbucket Cloud, because of the extra request
- * for HTML of the commit message.
- */
- async #getPullRequests(commitHash) {
- const projectKey = this.#getProjectKey();
- const repoSlug = this.#getRepositorySlug();
- const url = `/rest/api/latest/projects/${projectKey}/repos/${repoSlug}/commits/${commitHash}/pull-requests?start=0&limit=25`;
- try {
- const response = await fetch(url);
- const obj = await response.json();
- return obj.values;
- } catch (e) {
- error(`Cannot getPullRequests url="${url}"`, e);
- return [];
- }
- }
-
- /*
- * Inserts an HTML anchor to link to the pull requests, which are
- * mentioned in the provided `text` in the format that is used by
- * Bitbucket's default automatic merge commit messages.
- *
- * Tested only on Bitbucket Server.
- * Shouldn't be used on Bitbucket Cloud, because of the extra request
- * for HTML of the commit message.
- */
- async #insertPrLinks(text, commitHash) {
- if (!text.toLowerCase().includes('pull request')) {
- return text;
- }
- try {
- const prs = await this.#getPullRequests(commitHash);
- /*
- * Find the PR ID in the text.
- * Assume that there should be only one.
- */
- const m = new RegExp('pull request [#](\\d+)', 'gmi').exec(text);
- if (m.length != 2) {
- return text;
- }
- const linkText = m[0];
- const id = parseInt(m[1]);
- for (const pr of prs) {
- if (pr.id == id) {
- const prUrl = pr.links.self[0].href;
- text = text.replace(linkText, `<a href="${prUrl}">${linkText}</a>`);
- break;
- }
- }
- return text;
- } catch (e) {
- error("Cannot insert pull request links", e);
- return text;
- }
- }
-
- async #getApiDateIso(commitHash) {
- const t = await this.#getApiTimestamp(commitHash);
- const d = new Date(t);
- return d.toISOString().slice(0, 'YYYY-MM-DD'.length);
- }
-
- async #getApiTimestamp(commitHash) {
- const projectKey = this.#getProjectKey();
- const repoSlug = this.#getRepositorySlug();
- const url = `/rest/api/latest/projects/${projectKey}/repos/${repoSlug}/commits/${commitHash}`;
- try {
- const response = await fetch(url);
- const obj = await response.json();
- return obj.authorTimestamp;
- } catch (e) {
- error(`Cannot getApiTimestamp url="${url}"`, e);
- return NaN;
- }
- }
-
- onAuiVersion(eight, nine) {
- if (parseInt(document.body.dataset.auiVersion.split('.')[0]) > 8) {
- return nine();
- } else {
- return eight();
- }
- }
- }
-
- CopyCommitReference.runForGitHostings(new BitbucketCloud(), new BitbucketServer());
- })();