// ==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 // @license MIT // @include https://*bitbucket*/*/commits/* // @match https://bitbucket.example.com/*/commits/* // @match https://bitbucket.org/*/commits/* // @icon https://bitbucket.org/favicon.ico // @grant none // @downloadURL 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, `${issueKey}`); } 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, `${linkText}`); break; } } return text; } catch (e) { error("Cannot insert pull request links", e); return text; } } /* * Extracts first
tag out of the provided `html`. */ function 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); } /* * 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 = `${abbrev} (${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(`${linkText}`); anchor.onclick = onclick; return anchor; } function doAddLink() { onVersion( () => waitForElement('.commit-details'), () => waitForElement('.css-tbegx5.e1tw8lnx2') ).then(target => { debug('target', target); const container = htmlToElement(``); 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