GitHub: Copy Commit Reference

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

2023-08-16 या दिनांकाला. सर्वात नवीन आवृत्ती पाहा.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         GitHub: Copy Commit Reference
// @namespace    https://github.com/rybak
// @license      MIT
// @version      4-alpha
// @description  This script is obsolete. Please migrate to userscript "Git: copy commit reference"
// @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.
 */

/*
 * FIXME known issues;
 *   - On-the-fly loading from non-commit pages
 */

(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`
			//       though a shorted version (with ellipsis) might be better for HTML version
			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 REST API.
	 */
	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>`);
			if (document.getElementById(CONTAINER_ID) != null) {
				info("OOPS");
				return;
			}
			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();
	});
})();