// ==UserScript==
// @name GitHub: copy commit reference
// @namespace https://andrybak.dev
// @license AGPL-3.0-only
// @version 7
// @description Adds a "Copy commit reference" button to every commit page on GitHub.
// @homepageURL https://greasyfork.org/en/scripts/472870-github-copy-commit-reference
// @supportURL https://greasyfork.org/en/scripts/472870-github-copy-commit-reference/feedback
// @author Andrei Rybak
// @match https://github.com/*
// @icon https://github.githubassets.com/favicons/favicon-dark.png
// @require https://cdn.jsdelivr.net/gh/rybak/userscript-libs@e86c722f2c9cc2a96298c8511028f15c45180185/waitForElement.js
// @require https://cdn.jsdelivr.net/gh/rybak/copy-commit-reference-userscript@1306877cef88bb8792c0851e31454d9b7a82b262/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/>.
*/
(function () {
'use strict';
const LOG_PREFIX = '[GitHub: copy commit reference]:';
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 GitHub.
* This was tested on https://github.com, but wasn't tested in GitHub Enterprise.
*
* Example URL for testing:
* - Regular commit
* https://github.com/git/git/commit/1f0fc1db8599f87520494ca4f0e3c1b6fabdf997
* - Merge commit with PR mention:
* https://github.com/rybak/atlassian-tweaks/commit/fbeb0e54b64c894d9ba516db3a35c10bf409bfa6
* - Empty commit (no diff, i.e. no changes committed)
* https://github.com/rybak/copy-commit-reference-user-js/commit/234804fac57b39dd0017bc6f63aae1c1ce503d52
*/
class GitHub extends GitHosting {
/*
* Mandatory overrides.
*/
/*
* CSS selector to use to find the element, to which the button
* will be added.
*/
getTargetSelector() {
return '.commit.full-commit div:first-child';
}
getFullHash() {
if (GitHub.#isAPullRequestPage()) {
// commit pages in PRs have full SHA hashes
return document.querySelector('.commit.full-commit.prh-commit .commit-meta .sha.user-select-contain').childNodes[0].textContent;
}
/*
* path example: "/git/git/commit/1f0fc1db8599f87520494ca4f0e3c1b6fabdf997"
*/
const path = document.querySelector('a.js-permalink-shortcut').getAttribute('href');
const parts = path.split('/');
if (parts.length < 5) {
throw new Error("Cannot find commit hash in the URL");
}
return parts[4];
}
async getDateIso(hash) {
const commitJson = await this.#downloadJson(hash);
return commitJson.commit.author.date.slice(0, 'YYYY-MM-DD'.length);
}
async getCommitMessage() {
const commitJson = await this.#downloadJson();
return commitJson.commit.message;
}
/*
* Optional overrides.
*/
getButtonTagName() {
return 'span';
}
wrapButtonContainer(container) {
container.style = 'margin-right: 8px;';
return container;
}
/**
* @param {HTMLElement} target
* @param {HTMLElement} buttonContainer
*/
addButtonContainerToTarget(target, buttonContainer) {
// top-right corner
if (GitHub.#isAPullRequestPage()) {
// to the left of "< Prev | Next >" buttons (if present)
target.insertBefore(buttonContainer, target.childNodes[1]);
} else {
/*
* Unfortunately, native CSS relies on the fact that the parent element of
* the "Browse files" button has only two children.
* Rewrap the "Browse files" button for a correct layout.
*/
const browseButton = document.getElementById('browse-at-time-link');
target.removeChild(browseButton);
const rightHandSide = document.createElement('div');
rightHandSide.classList.add('flex-self-start');
browseButton.classList.remove('flex-self-start');
rightHandSide.appendChild(buttonContainer);
rightHandSide.appendChild(browseButton);
target.append(rightHandSide);
}
}
/**
* Styles adapted from GitHub's native CSS classes ".tooltipped::before"
* and ".tooltipped-s::before".
*
* @returns {HTMLElement}
*/
#createTooltipTriangle() {
const triangle = document.createElement('div');
triangle.style.position = 'absolute';
triangle.style.zIndex = '1000001';
triangle.style.top = 'calc(-100% + 15px)';
// aligns the base of triangle with the button's emoji
triangle.style.left = '0.45rem';
triangle.style.height = '0';
triangle.style.width = '0';
/*
* borders connect at 45° angle => when only bottom border is colored, it's a trapezoid
* but with width=0, the top edge of trapezoid has length 0, so it's a triangle
*/
triangle.style.border = '7px solid transparent';
triangle.style.borderBottomColor = 'var(--bgColor-emphasis, var(--color-neutral-emphasis-plus))';
return triangle;
}
createCheckmark() {
const checkmark = super.createCheckmark();
checkmark.style.zIndex = '1000000';
checkmark.style.top = 'calc(100% + 6px)';
if (GitHub.#isAPullRequestPage()) {
checkmark.style.left = '0.4em';
} else {
checkmark.style.left = '0.7em';
}
checkmark.style.marginTop = '7px';
checkmark.style.font = 'normal normal 11px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"';
checkmark.style.color = 'var(--fgColor-onEmphasis, var(--color-fg-on-emphasis))';
checkmark.style.background = 'var(--bgColor-emphasis, var(--color-neutral-emphasis-plus))';
checkmark.style.borderRadius = '6px';
checkmark.style.padding = '.5em .75em';
const triangle = this.#createTooltipTriangle();
checkmark.appendChild(triangle);
return checkmark;
}
async convertPlainSubjectToHtml(plainTextSubject) {
return await GitHub.#insertIssuePrLinks(plainTextSubject);
}
/*
* Adds CSS classes and a nice icon to mimic other buttons in GitHub UI.
*/
wrapButton(button) {
button.classList.add(
'Button--secondary', // for border and background-color
'Button', // for inner positioning of the icon
'btn' // for outer positioning like the button "Browse files"
);
if (GitHub.#isAPullRequestPage()) {
button.classList.add('Button--small'); // like buttons "< Prev | Next >"
}
try {
// GitHub's .octicon-copy is present on all pages, even if commit is empty
const icon = document.querySelector('.octicon-copy').cloneNode(true);
button.append(icon);
const buttonText = this.getButtonText();
button.replaceChildren(icon, document.createTextNode(` ${buttonText}`));
} catch (e) {
warn('Github: cannot find .octicon-copy');
}
return button;
}
static #isAGitHubCommitPage() {
const p = document.location.pathname;
/*
* Note that `pathname` doesn't include slashes from
* repository's directory structure.
*/
const slashIndex = p.lastIndexOf('/');
if (slashIndex <= 7) {
info('GitHub: not enough characters to be a commit page');
return false;
}
const beforeLastSlash = p.slice(slashIndex - 7, slashIndex);
/*
* '/commit' for regular commit pages:
* https://github.com/junit-team/junit5/commit/977c85fc31ad6825b4c68f6c6c972a93356ffe74
* 'commits' for commits in PRs:
* https://github.com/junit-team/junit5/pull/3416/commits/3fad8c6c2a3829e2e329b334cd49b19f179d5f1f
*/
if (beforeLastSlash != '/commit' && beforeLastSlash != 'commits' /* on PR pages */) {
info('GitHub: missing "/commit" in the URL. Got: ' + beforeLastSlash);
return false;
}
// https://stackoverflow.com/a/10671743/1083697
const numberOfSlashes = (p.match(/\//g) || []).length;
if (numberOfSlashes < 4) {
info('GitHub: This URL does not look like a commit page: not enough slashes');
return false;
}
info('GitHub: this URL needs a copy button');
return true;
}
static #isAPullRequestPage() {
return document.location.pathname.includes('/pull/');
}
#maybeEnsureButton(eventName, ensureButtonFn) {
info('GitHub: triggered', eventName);
this.#onPageChange();
if (GitHub.#isAGitHubCommitPage()) {
ensureButtonFn();
}
}
/*
* Handling of on-the-fly page loading.
*
* I found 'soft-nav:progress-bar:start' in a call stack in GitHub's
* own JS, and just tried replacing "start" with "end". So far, seems
* to work fine.
*/
setUpReadder(ensureButtonFn) {
/*
* When user clicks on another commit, e.g. on the parent commit.
*/
document.addEventListener('soft-nav:progress-bar:end', (event) => {
this.#maybeEnsureButton('progress-bar:end', ensureButtonFn);
});
/*
* When user goes back or forward in browser's history.
*/
window.addEventListener('popstate', (event) => {
/*
* Delay is needed, because 'popstate' seems to be
* triggered with old DOM.
*/
setTimeout(() => {
debug('After timeout:');
this.#maybeEnsureButton('popstate', ensureButtonFn);
}, 100);
});
info('GitHub: added re-adder listeners');
}
/*
* 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 GitHub. Reference documentation:
* https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit
*/
async #downloadJson(hash) {
if (this.#commitJson != null) {
return this.#commitJson;
}
try {
const commitRestUrl = GitHub.#getCommitRestApiUrl(hash);
info(`GitHub: Fetching "${commitRestUrl}"...`);
const commitResponse = await fetch(commitRestUrl, GitHub.#getRestApiOptions());
this.#commitJson = await commitResponse.json();
return this.#commitJson;
} catch (e) {
error("GitHub: cannot fetch commit JSON from REST API", e);
return null;
}
}
static #getApiHostUrl() {
const host = document.location.host;
return `https://api.${host}`;
}
static #getCommitRestApiUrl(hash) {
/*
* Format: /repos/{owner}/{repo}/commits/{ref}
* - NOTE: plural "commits" in the URL!!!
* Examples:
* - https://api.github.com/repos/git/git/commits/1f0fc1db8599f87520494ca4f0e3c1b6fabdf997
* - https://api.github.com/repos/rybak/atlassian-tweaks/commits/a76a9a6e993a7a0e48efabdd36f4c893317f1387
*/
const apiHostUrl = GitHub.#getApiHostUrl();
const ownerSlashRepo = document.querySelector('[data-current-repository]').getAttribute('data-current-repository');
return `${apiHostUrl}/repos/${ownerSlashRepo}/commits/${hash}`;
}
static #getRestApiOptions() {
const myHeaders = new Headers();
myHeaders.append("Accept", "application/vnd.github+json");
const myInit = {
headers: myHeaders,
};
return myInit;
}
/*
* Inserts an HTML anchor to link to issues and pull requests, which are
* mentioned in the provided `text` in the `#<number>` format.
*/
static #insertIssuePrLinks(text) {
if (!text.toLowerCase().includes('#')) {
return text;
}
try {
// a hack: just get the existing HTML from the GUI
// the hack probably doesn't work very well with overly long subject lines
// TODO: proper conversion of `text`
// though a shorter version (with ellipsis) might be better for HTML version
return document.querySelector('.commit-title.markdown-title').innerHTML.trim();
} catch (e) {
error("GitHub: cannot insert issue or pull request links", e);
return text;
}
}
}
CopyCommitReference.runForGitHostings(new GitHub());
})();