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'; 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, `${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; } } 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()); })();