GitHub: Copy Commit Reference

Adds a "Copy commit reference" link to every commit page.

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

// ==UserScript==
// @name         GitHub: Copy Commit Reference
// @namespace    https://github.com/rybak
// @license      MIT
// @version      2-alpha
// @description  Adds a "Copy commit reference" link to every commit page.
// @author       Andrei Rybak
// @include      https://*github*/*/commit/*
// @match        https://github.example.com/*/commit/*
// @match        https://github.com/*/commit/*
// @icon         https://github.githubassets.com/favicons/favicon-dark.png
// @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.
 */

(function() {
	'use strict';

	const LOG_PREFIX = '[GitHub: copy commit reference]:';
	const CONTAINER_ID = "GHCCR_container";
	const CHECKMARK_ID = "GHCCR_checkmark";
	let inProgress = false;

	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);
	}

	/*
	 * 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) {
		debug(`plainTextCommitReference("${commitId}", "${subject}", "${dateIso}")`);
		const abbrev = abbreviateCommitId(commitId);
		return `${abbrev} (${subject}, ${dateIso})`;
	}

	/*
	 * Inserts an HTML anchor to link to the pull requests, which are
	 * mentioned in the provided `text` in the format that is used by
	 * GitHub's default automatic merge commit messages.
	 */
	async function insertPrLinks(text, commitId) {
		if (!text.toLowerCase().includes('pull request')) {
			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`
			return document.querySelector('.commit-title.markdown-title').innerHTML.trim();
		} catch (e) {
			error("Cannot insert pull request links", e);
			return text;
		}
	}

	/*
	 * Renders given commit that has the provided subject line and date
	 * in reference format as HTML content, which includes a clickable
	 * link to the commit.
	 *
	 * Documentation of formats: https://git-scm.com/docs/git-log#_pretty_formats
	 */
	async function htmlSyntaxLink(commitId, subject, dateIso) {
		const url = document.location.href;
		const abbrev = abbreviateCommitId(commitId);
		let subjectHtml;
		subjectHtml = await insertPrLinks(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);
	}

	function getApiHostUrl() {
		const host = document.location.host;
		return `https://api.${host}`;
	}

	function getFullCommitId() {
		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");
		}
		const commitId = parts[4];
		return commitId;
	}

	function getCommitRestApiUrl(commitId) {
		// /repos/{owner}/{repo}/commits/{ref}
		// e.g. https://api.github.com/repos/rybak/atlassian-tweaks/commits/a76a9a6e993a7a0e48efabdd36f4c893317f1387
		// NOTE: plural "commits" in the URL!!!
		const apiHostUrl = getApiHostUrl();
		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");
		}
		const owner = parts[1];
		const repo = parts[2];
		return `${apiHostUrl}/repos/${owner}/${repo}/commits/${commitId}`;
	}

	function getRestApiOptions() {
		const myHeaders = new Headers();
		myHeaders.append("Accept", "application/vnd.github+json");
		const myInit = {
			headers: myHeaders,
		};
		return myInit;
	}

	/*
	 * 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 commitJson;
			const commitId = getFullCommitId();

			try {
				const commitRestUrl = getCommitRestApiUrl(commitId);
				info(`Fetching "${commitRestUrl}"...`);
				const commitResponse = await fetch(commitRestUrl, getRestApiOptions());
				commitJson = await commitResponse.json();
			} catch (e) {
				error("Cannot fetch commit JSON from REST API", e);
			}
			/*
			 * If loaded successfully, extract particular parts of
			 * the JSON that we are interested in.
			 */
			const dateIso = commitJson.commit.author.date.slice(0, 'YYYY-MM-DD'.length);
			const commitMessage = commitJson.commit.message;
			const subject = commitMessageToSubject(commitMessage);

			const plainText = plainTextCommitReference(commitId, subject, dateIso);
			const html = await htmlSyntaxLink(commitId, subject, dateIso);
			info("plain text:", plainText);
			info("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 showCheckmark() {
		const checkmark = document.getElementById(CHECKMARK_ID);
		checkmark.style.display = 'inline';
	}

	function hideCheckmark() {
		const checkmark = document.getElementById(CHECKMARK_ID);
		checkmark.style.display = 'none';
	}

	function createCopyLink() {
		const onclick = (event) => {
			showCheckmark();
			copyClickAction(event);
			setTimeout(hideCheckmark, 2000);
		}

		const linkText = "Copy commit reference";
		const style = 'margin-left: 1em;';
		const anchor = htmlToElement(`<a href="#" style="${style}" class="Link--onHover color-fg-muted"></a>`);
		const icon = document.querySelector('.octicon-copy').cloneNode(true);
		icon.classList.remove('color-fg-muted');
		anchor.append(icon);
		anchor.append(` ${linkText}`);
		anchor.onclick = onclick;
		return anchor;
	}

	function createCheckmark() {
		const container = document.createElement('span');
		container.id = CHECKMARK_ID;
		container.style.display = 'none';
		container.innerHTML = " ✅ Copied!";
		return container;
	}

	function doAddLink() {
		waitForElement('.commit.full-commit .commit-meta div.flex-self-start.flex-content-center').then(target => {
			debug('target', target);
			const container = htmlToElement(`<span id="${CONTAINER_ID}"></span>`);
			target.append(container);
			const link = createCopyLink();
			container.append(' ');
			container.appendChild(link);
			container.append(createCheckmark());
		});
	}

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

	function ensureLink() {
		if (inProgress) {
			return;
		}
		inProgress = true;
		try {
			removeExistingContainer();
			/*
			 * Need this tag to have parent for the container.
			 */
			waitForElement('.commit.full-commit .commit-meta').then(loadedBody => {
				doAddLink();
				if (document.getElementById(CONTAINER_ID) == null) {
					ensureLink();
				}
			});
		} catch (e) {
			error('Could not create the button', e);
		} finally {
			inProgress = false;
		}
	}

	ensureLink();

	/*
	 * Handling of on-the-fly page loading.
	 *
	 *   - The usual MutationObserver on <title> doesn't work.
	 *   - None of the below event listeners work:
	 *     - https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event
	 *     - https://developer.mozilla.org/en-US/docs/Web/API/Window/hashchange_event
	 *     - https://developer.mozilla.org/en-US/docs/Web/API/Window/load_event
	 *
	 * 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.
	 */
	document.addEventListener('soft-nav:progress-bar:end', (event) => {
		info("progress-bar:end", event);
		ensureLink();
	});
})();

QingJ © 2025

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