Bitbucket: copy commit reference

This userscript is obsolete. Please migrate to userscript "Git: copy commit reference"

目前为 2023-08-28 提交的版本。查看 最新版本

// ==UserScript==
// @name         Bitbucket: copy commit reference
// @namespace    https://github.com/rybak/atlassian-tweaks
// @version      4.OBSOLETE
// @description  This userscript is obsolete. Please migrate to userscript "Git: copy commit reference"
// @author       Andrei Rybak
// @include      https://*bitbucket*/*/commits/*
// @match        https://bitbucket.example.com/*/commits/*
// @match        https://bitbucket.org/*/commits/*
// @icon         https://bitbucket.org/favicon.ico
// @grant        none
// ==/UserScript==

/*
 * Copyright (c) 2023 Andrei Rybak
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

/*
 * 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 log(...toLog) {
		console.log(LOG_PREFIX, ...toLog);
	}

	function debug(...toLog) {
		console.debug(LOG_PREFIX, ...toLog);
	}

	/*
	 * Detects the kind of Bitbucket, invokes corresponding function:
	 * `serverFn` or `cloudFn`, and returns result of the invocation.
	 */
	function onVersion(serverFn, cloudFn) {
		if (document.querySelector('meta[name="bb-single-page-app"]') == null) {
			return serverFn();
		}
		const b = document.body;
		const auiVersion = b.getAttribute('data-aui-version');
		if (!auiVersion) {
			return cloudFn();
		}
		if (auiVersion.startsWith('7.')) {
			/*
			 * This is weird, but unlike for Jira Server vs Jira Cloud,
			 * Bitbucket Cloud's AUI version is smaller than AUI version
			 * of current-ish Bitbucket Server.
			 */
			return cloudFn();
		}
		if (auiVersion.startsWith('9.')) {
			return serverFn();
		}
		// TODO more ways of detecting the kind of Bitbucket
		cloudFn();
	}

	/*
	 * Extracts the first line of the commit message.
	 * If the first line is too small, extracts more lines.
	 */
	function commitMessageToSubject(commitMessage) {
		const lines = commitMessage.split('\n');
		if (lines[0].length > 16) {
			/*
			 * Most common use-case: a normal commit message with
			 * a normal-ish subject line.
			 */
			return lines[0].trim();
		}
		/*
		 * The `if`s below handle weird commit messages I have
		 * encountered in the wild.
		 */
		if (lines.length < 2) {
			return lines[0].trim();
		}
		if (lines[1].length == 0) {
			return lines[0].trim();
		}
		// sometimes subject is weirdly split across two lines
		return lines[0].trim() + " " + lines[1].trim();
	}

	function abbreviateCommitId(commitId) {
		return commitId.slice(0, 7)
	}

	/*
	 * Formats given commit metadata as a commit reference according
	 * to `git log --format=reference`.  See format descriptions at
	 * https://git-scm.com/docs/git-log#_pretty_formats
	 */
	function plainTextCommitReference(commitId, subject, dateIso) {
		const abbrev = abbreviateCommitId(commitId);
		return `${abbrev} (${subject}, ${dateIso})`;
	}

	/*
	 * 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.
	 */
	function getIssueKeys() {
		const issuesElem = document.querySelector('.plugin-section-primary .commit-issues-trigger');
		if (!issuesElem) {
			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 function 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 function insertJiraLinks(text) {
		const issueKeys = getIssueKeys();
		if (issueKeys.length == 0) {
			return text;
		}
		debug("issueKeys:", issueKeys);
		for (const issueKey of issueKeys) {
			if (text.includes(issueKey)) {
				try {
					const issueUrl = await 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;
	}

	function getProjectKey() {
		return document.querySelector('[data-project-key]').getAttribute('data-project-key');
	}

	function 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 function getPullRequests(commitId) {
		const projectKey = getProjectKey();
		const repoSlug = getRepositorySlug();
		const url = `/rest/api/latest/projects/${projectKey}/repos/${repoSlug}/commits/${commitId}/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 function insertPrLinks(text, commitId) {
		if (!text.toLowerCase().includes('pull request')) {
			return text;
		}
		try {
			const prs = await getPullRequests(commitId);
			/*
			 * 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;
		}
	}

	/*
	 * Extracts first <p> tag out of the provided `html`.
	 */
	function 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);
	}

	/*
	 * Renders given commit that has the provided subject line and date
	 * in reference format as HTML content, which includes clickable
	 * links to commits, pull requests, and Jira issues.
	 *
	 * Parameter `htmlSubject`:
	 *     Pre-rendered HTML of the subject line of the commit. Optional.
	 *
	 * Documentation of formats: https://git-scm.com/docs/git-log#_pretty_formats
	 */
	async function htmlSyntaxLink(commitId, subject, dateIso, htmlSubject) {
		const url = document.location.href;
		const abbrev = abbreviateCommitId(commitId);
		let subjectHtml;
		if (htmlSubject && htmlSubject.length > 0) {
			subjectHtml = htmlSubject;
		} else {
			subjectHtml = await insertPrLinks(await insertJiraLinks(subject), commitId);
		}
		debug("subjectHtml", subjectHtml);
		const html = `<a href="${url}">${abbrev}</a> (${subjectHtml}, ${dateIso})`;
		return html;
	}

	function addLinkToClipboard(event, plainText, html) {
		event.stopPropagation();
		event.preventDefault();

		let clipboardData = event.clipboardData || window.clipboardData;
		clipboardData.setData('text/plain', plainText);
		clipboardData.setData('text/html', html);
	}

	/*
	 * Generates the content and passes it to the clipboard.
	 *
	 * Async, because we need to access Jira integration via REST API
	 * to generate the fancy HTML, with links to Jira.
	 */
	async function copyClickAction(event) {
		event.preventDefault();
		try {
			/*
			 * Extract metadata about the commit from the UI.
			 */
			let commitId, commitMessage, dateIso;
			[commitId, commitMessage, dateIso] = onVersion(
				() => {
					const commitAnchor = document.querySelector('.commit-badge-oneline .commit-details .commitid');
					const commitTimeTag = document.querySelector('.commit-badge-oneline .commit-details time');
					const commitMessage = commitAnchor.getAttribute('data-commit-message');
					const dateIso = commitTimeTag.getAttribute('datetime').slice(0, 'YYYY-MM-DD'.length);
					const commitId = commitAnchor.getAttribute('data-commitid');
					return [commitId, commitMessage, dateIso];
				},
				() => {
					const commitIdTag = document.querySelector('.css-tbegx5.e1tw8lnx2 strong+strong');
					let dateStr;
					try {
						const commitTimeTag = document.querySelector('.css-tbegx5.e1tw8lnx2 time');
						dateStr = commitTimeTag.getAttribute('datetime').slice(0, 'YYYY-MM-DD'.length);
					} catch (e) {
						/*
						 * When a commit is recent, Bitbucket Cloud shows a human-readable string
						 * such as "4 days ago" or "19 minutes ago". This string is localized,
						 * and the `title` attribute of the corresponding HTML tag is also localized.
						 * There is no ISO 8601 timestamp easily available.
						 */
						warn("No time tag :-(", e);
						dateStr = null;
					}

					const commitMsgContainer = document.querySelector('.css-1qa9ryl.e1tw8lnx1+div');
					return [
						commitIdTag.innerText,
						commitMsgContainer.innerText,
						dateStr /* can't extract ISO date in Bitbucket Cloud from UI in _all_ cases */
					];
				}
			);
			/*
			 * Load pre-rendered HTML.
			 */
			let htmlSubject;
			await onVersion(
				() => {
					/* Bitbucket Server doesn't need additional requests.
					 * Just initialize `htmlSubject` to an empty string for
					 * function `htmlSyntaxLink` down the line. */
					htmlSubject = "";
				},
				async () => {
					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 commitRestUrl = `/!api/2.0/repositories/${projectKeyRepoSlug}/commit/${commitId}?fields=%2B%2A.rendered.%2A`;
						log(`Fetching "${commitRestUrl}"...`);
						const commitResponse = await fetch(commitRestUrl);
						const commitJson = await commitResponse.json();
						/*
						 * If loaded successfully, extract particular parts of
						 * the JSON that we are interested in.
						 */
						dateIso = commitJson.date.slice(0, 'YYYY-MM-DD'.length);
						htmlSubject = firstHtmlParagraph(commitJson.summary.html);
					} catch (e) {
						error("Cannot fetch commit JSON from REST API", e);
					}
				}
			);

			const subject = commitMessageToSubject(commitMessage);

			const plainText = plainTextCommitReference(commitId, subject, dateIso);
			const html = await htmlSyntaxLink(commitId, subject, dateIso, htmlSubject);
			log("plain text:", plainText);
			log("HTML:", html);

			const handleCopyEvent = e => {
				addLinkToClipboard(e, plainText, html);
			};
			document.addEventListener('copy', handleCopyEvent);
			document.execCommand('copy');
			document.removeEventListener('copy', handleCopyEvent);
		} catch (e) {
			error('Could not do the copying', e);
		}
	}

	// from https://stackoverflow.com/a/61511955/1083697 by Yong Wang
	function waitForElement(selector) {
		return new Promise(resolve => {
			if (document.querySelector(selector)) {
				return resolve(document.querySelector(selector));
			}
			const observer = new MutationObserver(mutations => {
				if (document.querySelector(selector)) {
					resolve(document.querySelector(selector));
					observer.disconnect();
				}
			});

			observer.observe(document.body, {
				childList: true,
				subtree: true
			});
		});
	}

	// adapted from https://stackoverflow.com/a/35385518/1083697 by Mark Amery
	function htmlToElement(html) {
		const template = document.createElement('template');
		template.innerHTML = html.trim();
		return template.content.firstChild;
	}

	function copyLink() {
		const onclick = (event) => copyClickAction(event);

		const linkText = "Copy commit reference";
		const style = 'margin-left: 1em;';
		const anchor = htmlToElement(`<a href="#" style="${style}">${linkText}</a>`);
		anchor.onclick = onclick;
		return anchor;
	}

	function doAddLink() {
		onVersion(
			() => waitForElement('.commit-details'),
			() => waitForElement('.css-tbegx5.e1tw8lnx2')
		).then(target => {
			debug('target', target);
			const container = htmlToElement(`<span id="${CONTAINER_ID}"></span>`);
			target.append(container);
			const link = copyLink();
			container.append(' ');
			container.appendChild(link);
		});
	}

	function removeExistingContainer() {
		const container = document.getElementById(CONTAINER_ID);
		if (!container) {
			return;
		}
		container.parentNode.removeChild(container);
	}

	function ensureLink() {
		removeExistingContainer();
		try {
			/*
			 * Need this attribute to detect the kind of Bitbucket: Server or Cloud.
			 */
			waitForElement('[data-aui-version]')
				.then(loadedBody => doAddLink());
		} catch (e) {
			error('Could not create the button', e);
		}
	}

	ensureLink();

	/*
	 * Clicking on a commit link on Bitbucket Cloud doesn't trigger a page load
	 * (sometimes, at least).  To cover such cases, we need to automatically
	 * detect that the commit in the URL has changed.
	 *
	 * For whatever reason listener for popstate events doesn't work to
	 * detect a change in the URL.
	 * https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event
	 *
	 * As a workaround, observe the changes in the <title> tag, since commits
	 * will have different <title>s.
	 */
	let currentUrl = document.location.href;
	const observer = new MutationObserver((mutationsList) => {
		const maybeNewUrl = document.location.href;
		log('Mutation to', maybeNewUrl);
		if (maybeNewUrl != currentUrl) {
			currentUrl = maybeNewUrl;
			log('MutationObserver: URL has changed:', currentUrl);
			ensureLink();
		}
	});
	observer.observe(document.querySelector('title'), { subtree: true, characterData: true, childList: true });
	log('Added MutationObserver');
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址