Skip to content

Instantly share code, notes, and snippets.

@mu-hun
Last active May 8, 2026 02:38
Show Gist options
  • Select an option

  • Save mu-hun/eb5fd44a3f72f49cefc825f65c444c55 to your computer and use it in GitHub Desktop.

Select an option

Save mu-hun/eb5fd44a3f72f49cefc825f65c444c55 to your computer and use it in GitHub Desktop.
GitHub Copy Issue for @AdguardTeam Filters Maintenance
// ==UserScript==
// @name GitHub Copy Issue
// @namespace https://github.com/AdguardTeam/
// @version 1.0.0
// @description Adds Copy Fix / Copy Upd buttons to GitHub issue pages
// @author Mu-Hun
// @match https://github.com/AdguardTeam/AdguardFilters/issues/*
// @match https://github.com/issues/*
// @run-at document-start
// @downloadURL https://gist.githubusercontent.com/mu-hun/eb5fd44a3f72f49cefc825f65c444c55/raw/github-copy-issue.js
// @updateURL https://gist.githubusercontent.com/mu-hun/eb5fd44a3f72f49cefc825f65c444c55/raw/github-copy-issue.js
// @grant none
// @license MIT
// ==/UserScript==
;(function () {
'use strict'
const SELECTORS = {
title: '[data-testid="issue-title"]',
header: '[data-testid="issue-header"]',
number:
'[data-testid="issue-title"] + span[class^="HeaderViewer-module__issueNumberText--"], [data-testid="issue-title"] ~ span',
actionsAll: 'div[class^="HeaderMenu-module__menuActionsContainer--"]',
}
const CSS_PREFIX = 'gm-copy-issue'
const COLOR_FIX = '#30A147'
const COLOR_UPD = '#006EDB'
const COLOR_TEXT = '#fff'
const CONFIG = { debounce: 80, checkMs: 1200, initDelay: 40 }
;(function injectStyles() {
const css = `
:root{ --gm-fix:${COLOR_FIX}; --gm-upd:${COLOR_UPD}; --gm-text:${COLOR_TEXT}; }
.${CSS_PREFIX}-btn{ position:relative; display:inline-flex; align-items:center; gap:8px; padding:6px 12px; font-size:13px; border-radius:6px; border:1px solid rgba(0,0,0,0.12); color:var(--gm-text); cursor:pointer; box-shadow:0 1px 0 rgba(0,0,0,0.06); margin-left:8px; z-index:1; font-weight:600; font-family:inherit; line-height:1; overflow:visible; background-clip:padding-box; -webkit-font-smoothing:antialiased; }
.${CSS_PREFIX}-btn svg{ display:block; fill:none; stroke:currentColor; }
.${CSS_PREFIX}-btn .gm-btn-icon{ width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; flex:0 0 auto; line-height:0; }
.${CSS_PREFIX}-btn .gm-btn-text{ display:inline-block; visibility:visible; }
.${CSS_PREFIX}-btn .gm-btn-check{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); visibility:hidden; display:inline-flex; align-items:center; justify-content:center; pointer-events:none; line-height:0; }
.${CSS_PREFIX}-btn:focus{ outline:2px solid rgba(255,255,255,0.12); outline-offset:2px; box-shadow:0 2px 6px rgba(0,0,0,0.08); }
.${CSS_PREFIX}-adguard-fix{ background:var(--gm-fix); } .${CSS_PREFIX}-adguard-upd{ background:var(--gm-upd); }
.${CSS_PREFIX}-other-fix{ background:var(--gm-fix); } .${CSS_PREFIX}-other-upd{ background:var(--gm-upd); }
/* Adaptive layout for title+metadata to avoid collapse */
div[class^="HeaderMetadata-module__titleAndMetadata--"]{
box-sizing:border-box !important;
min-width:320px !important;
max-width:calc(100% - 220px) !important;
width:auto !important;
}
@media (max-width:760px){
div[class^="HeaderMetadata-module__titleAndMetadata--"]{ min-width:0 !important; max-width:100% !important; width:100% !important; }
div[class^="HeaderMenu-module__menuActionsContainer--"]{ margin-top:8px !important; }
}
[data-gm-generated="true"]{}
`
const style = document.createElement('style')
style.setAttribute('data-gm-style', 'v2')
style.textContent = css
;(document.head || document.documentElement).appendChild(style)
})()
let toastEl = null
let toastTimer = null
function showToast(text) {
try {
if (!toastEl) {
toastEl = document.createElement('div')
Object.assign(toastEl.style, {
position: 'fixed',
left: '16px',
bottom: '16px',
zIndex: 2147483647,
padding: '8px 12px',
background: 'rgba(0,0,0,0.78)',
color: '#fff',
borderRadius: '6px',
fontSize: '13px',
pointerEvents: 'none',
transition: 'opacity 150ms',
opacity: '0',
})
document.body.appendChild(toastEl)
}
toastEl.textContent = text
toastEl.style.opacity = '1'
toastTimer && clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
try {
toastEl.style.opacity = '0'
} catch {}
}, 1200)
} catch {}
}
function resolveIssueNumber(numberText) {
if (!numberText) {
const pathMatch = location.pathname.match(/\/issues\/(\d+)(?:$|\/)/)
if (pathMatch) return pathMatch[1]
const q = new URLSearchParams(location.search).get('issue')
if (q) {
const parts = q.split('|')
if (parts[2]) return parts[2]
}
return ''
}
const m = String(numberText).match(/#?(\d+)/)
return m && m[1] ? m[1] : ''
}
function buildCopyText({ mode, prefix, titleRaw, numberText }) {
const title = (titleRaw || '').trim()
if ('adguard' === mode) {
const number = resolveIssueNumber(numberText)
return number
? `${prefix} #${number} ${title}`.trim()
: `${prefix} ${title}`.trim()
}
return `${prefix} ${title || location.hostname || ''} ${location.href || ''}`.trim()
}
const btnTemplate = document.createElement('template')
btnTemplate.innerHTML = `
<button type="button" class="${CSS_PREFIX}-btn" data-gm-generated="true" aria-pressed="false">
<span class="gm-btn-icon"></span>
<span class="gm-btn-text"></span>
<span class="gm-btn-check"></span>
</button>
`
function filterIconSvg(size = 16, strokeWidth = 12) {
return `<svg width="${size}" height="${size}" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M112.001,229.05469A12.00583,12.00583,0,0,1,100,217.05176v-77.959a3.98926,3.98926,0,0,0-1.03955-2.68945l-65.75537-72.331A12,12,0,0,1,42.08447,44H213.91553a12,12,0,0,1,8.87939,20.07227l-65.75439,72.33007A3.98975,3.98975,0,0,0,156,139.09277v56.626a11.97406,11.97406,0,0,1-5.34424,9.98437l-31.99951,21.333A11.98627,11.98627,0,0,1,112.001,229.05469ZM42.08447,52A4.00015,4.00015,0,0,0,39.125,58.69141l65.75439,72.33007A11.971,11.971,0,0,1,108,139.09277v77.959a3.99924,3.99924,0,0,0,6.21826,3.32812l32.00049-21.333A3.99,3.99,0,0,0,148,195.71875v-56.626a11.97153,11.97153,0,0,1,3.12158-8.07226L216.875,58.69141A4.00015,4.00015,0,0,0,213.91553,52Z" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"/></svg>`
}
function checkIconSvg() {
return '<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 8.2L6.5 11.2L12.5 4.8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>'
}
function createButton({ role, mode, prefix, label, titleAttr }) {
const btn = btnTemplate.content.firstElementChild.cloneNode(true)
btn.dataset.gmRole = role
btn.dataset.gmMode = mode
btn.dataset.gmPrefix = prefix
btn.title = titleAttr || label
btn.setAttribute('aria-label', titleAttr || label)
btn.querySelector('.gm-btn-icon').innerHTML = filterIconSvg(16, 12)
btn.querySelector('.gm-btn-text').textContent = label
btn.querySelector('.gm-btn-check').innerHTML = checkIconSvg()
btn.classList.add(`${CSS_PREFIX}-${mode}-${prefix.toLowerCase()}`)
return btn
}
function injectButtons(container) {
if (!(container && container instanceof Element)) return
const mode = 'adguard'
const hasBtn = (role) =>
!!container.querySelector(
`[data-gm-generated="true"][data-gm-role="${role}"][data-gm-mode="${mode}"]`
)
if (!hasBtn('upd')) {
container.appendChild(
createButton({
role: 'upd',
mode,
prefix: 'Upd',
label: 'Copy Upd',
titleAttr: 'Copy: Upd #000000 example.com',
})
)
}
if (!hasBtn('fix')) {
container.appendChild(
createButton({
role: 'fix',
mode,
prefix: 'Fix',
label: 'Copy Fix',
titleAttr: 'Copy: Fix #000000 example.com',
})
)
}
}
document.addEventListener(
'click',
(e) => {
const btn = e.target.closest('[data-gm-generated="true"]')
if (!btn) return
e.stopPropagation()
e.preventDefault()
const prefix =
btn.dataset.gmPrefix || ('fix' === btn.dataset.gmRole ? 'Fix' : 'Upd')
const mode = btn.dataset.gmMode || 'adguard'
const titleEl = document.querySelector(SELECTORS.title)
const numberEl = document.querySelector(SELECTORS.number)
const text = buildCopyText({
mode,
prefix,
titleRaw: titleEl
? (titleEl.innerText || titleEl.textContent || '').trim()
: '',
numberText: numberEl
? (numberEl.innerText || numberEl.textContent || '').trim()
: '',
})
const btnText = btn.querySelector('.gm-btn-text')
const btnIcon = btn.querySelector('.gm-btn-icon')
const btnCheck = btn.querySelector('.gm-btn-check')
btn._timer && clearTimeout(btn._timer)
async function copyText(t) {
if (!t) return showToast('Nothing to copy')
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(t)
} else {
const ta = document.createElement('textarea')
ta.value = t
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
ta.remove()
}
showToast('Copied')
} catch (err) {
console.error('copy failed', err)
showToast('Copy failed')
}
}
copyText(text)
btn._showing = true
btn.style.filter = 'brightness(0.78)'
btnText && (btnText.style.visibility = 'hidden')
btnIcon && (btnIcon.style.visibility = 'hidden')
btnCheck && (btnCheck.style.visibility = 'visible')
btn.setAttribute('aria-pressed', 'true')
btn._timer = setTimeout(() => {
btn._showing = false
btn.style.filter = ''
btnText && (btnText.style.visibility = 'visible')
btnIcon && (btnIcon.style.visibility = 'visible')
btnCheck && (btnCheck.style.visibility = 'hidden')
btn.removeAttribute('aria-pressed')
btn._timer = null
}, CONFIG.checkMs)
},
true
)
let observer = null
let debounceTimer = null
function scheduleCheck() {
debounceTimer && clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
const run = () => {
try {
document.querySelector(SELECTORS.title)
const containers = Array.from(
document.querySelectorAll(SELECTORS.actionsAll)
)
const titleEl = document.querySelector(SELECTORS.title)
const headerEl =
document.querySelector(SELECTORS.header) ||
(titleEl && (titleEl.closest('div') || titleEl.parentElement))
if (headerEl && !containers.includes(headerEl))
containers.push(headerEl)
containers.forEach(injectButtons)
} catch (err) {
console.error(err)
}
}
'requestIdleCallback' in window
? requestIdleCallback(run, { timeout: 200 })
: setTimeout(run, 20)
}, CONFIG.debounce)
}
function startObserver() {
if (observer)
try {
observer.disconnect()
} catch {}
observer = new MutationObserver((mutations) => {
let changed = false
for (const m of mutations) {
if ('childList' === m.type && m.addedNodes && m.addedNodes.length) {
changed = true
break
}
if ('attributes' === m.type) {
changed = true
break
}
}
if (changed) scheduleCheck()
})
try {
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'data-component'],
})
} catch {}
}
function onNavigate() {
startObserver()
setTimeout(scheduleCheck, CONFIG.initDelay)
}
;(function patchHistory() {
try {
const origPush = history.pushState
const origReplace = history.replaceState
history.pushState = function () {
const result = origPush.apply(this, arguments)
window.dispatchEvent(new Event('locationchange'))
return result
}
history.replaceState = function () {
const result = origReplace.apply(this, arguments)
window.dispatchEvent(new Event('locationchange'))
return result
}
window.addEventListener('popstate', () =>
window.dispatchEvent(new Event('locationchange'))
)
} catch {}
})()
window.addEventListener('locationchange', onNavigate, true)
document.addEventListener('pjax:end', onNavigate, true)
document.addEventListener('turbo:load', onNavigate, true)
;(function init() {
try {
startObserver()
setTimeout(scheduleCheck, CONFIG.initDelay)
window.addEventListener('unload', () => {
observer && observer.disconnect()
document
.querySelectorAll('[data-gm-generated="true"]')
.forEach((el) => el.remove())
toastTimer && clearTimeout(toastTimer)
})
} catch (err) {
console.error('init', err)
}
})()
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment