- // ==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();
- });
- })();