// ==UserScript==
// @name Bitbucket: copy commit reference
// @namespace https://github.com/rybak/atlassian-tweaks
// @version 5
// @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
// @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@c7f2c3b96fd199ceee46de4ba7eb6315659b34e3/copy-commit-reference-lib.js
// @grant none
// @downloadURL none
// ==/UserScript==
/*
* Copyright (C) 2023 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
tag out of the provided `html`. */ static #firstHtmlParagraph(html) { const OPEN_P_TAG = '
'; const CLOSE_P_TAG = '
'; 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'; 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; } getTargetSelector() { return '.plugin-section-secondary'; } 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'); const buttonText = this.getButtonText(); button.replaceChildren(icon, document.createTextNode(` ${buttonText}`)); 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() { const commitAnchor = document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR); const commitHash = commitAnchor.getAttribute('data-commitid'); return commitHash; } getDateIso(hash) { const commitTimeTag = document.querySelector('.commit-badge-oneline .commit-details time'); const dateIso = commitTimeTag.getAttribute('datetime').slice(0, 'YYYY-MM-DD'.length); return dateIso; } getCommitMessage(hash) { const commitAnchor = document.querySelector(BitbucketServer.#SHA_LINK_SELECTOR); const commitMessage = commitAnchor.getAttribute('data-commit-message'); return commitMessage; } async convertPlainSubjectToHtml(plainTextSubject, commitHash) { return await this.#insertPrLinks(await this.#insertJiraLinks(plainTextSubject), 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) { warn("Cannot find issues element"); 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, `${issueKey}`); } 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, `${linkText}`); break; } } return text; } catch (e) { error("Cannot insert pull request links", e); return text; } } } CopyCommitReference.runForGitHostings(new BitbucketCloud(), new BitbucketServer()); })();