您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a "Copy commit reference" button to every commit page on GitHub.
当前为
// ==UserScript== // @name GitHub: copy commit reference // @namespace https://andrybak.dev // @license AGPL-3.0-only // @version 8 // @description Adds a "Copy commit reference" button to every commit page on GitHub. // @homepageURL https://gf.zukizuki.org/en/scripts/472870-github-copy-commit-reference // @supportURL https://gf.zukizuki.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@4f71749bc0d302d4ff4a414b0f4a6eddcc6a56ad/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, commitHash) { const escapedHtml = await super.convertPlainSubjectToHtml(plainTextSubject, commitHash); return await GitHub.#insertIssuePrLinks(escapedHtml); } /* * 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()); })();