|
// ==UserScript== |
|
// @name GitHub Issues Instant Solutions |
|
// @namespace https://github.com/bengry |
|
// @version 2.2.0 |
|
// @description Find the best solution to a GitHub Issue instantly by analyzing comment reactions (2025 Rewrite) |
|
// @author bengry (original idea by Martin Galovic) |
|
// @match https://github.com/*/*/issues/* |
|
// @match https://www.github.com/*/*/issues/* |
|
// @grant GM_addStyle |
|
// @run-at document-idle |
|
// @license MIT |
|
// @homepageURL https://gist.github.com/bengry/eb0f4a62d2194c9eccac737d0bdc05bd |
|
// ==/UserScript== |
|
|
|
(function () { |
|
("use strict"); |
|
|
|
GM_addStyle(` |
|
.__cghr__POSSIBLE { |
|
border-radius: 0 !important; |
|
} |
|
|
|
.__cghr__footnote { |
|
display: flex; |
|
width: 100%; |
|
align-items: center; |
|
justify-content: space-between; |
|
background-color: #F6F8FA; |
|
color: #576069; |
|
padding: 10px 15px; |
|
border-top: 1px solid #D1D5D9; |
|
margin-top: 8px; |
|
gap: 15px; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.__cghr__footnote .octicon-heart { |
|
color: #e74c3c; |
|
position: relative; |
|
top: 1px; |
|
margin-right: 3px; |
|
} |
|
|
|
.__cghr__footnote .__cghr__description { |
|
flex-shrink: 0; |
|
font-weight: 600; |
|
font-size: 14px; |
|
display: flex; |
|
align-items: center; |
|
gap: 6px; |
|
} |
|
|
|
.__cghr__footnote .__cghr__actions { |
|
font-weight: 500; |
|
font-size: 12px; |
|
text-align: right; |
|
display: flex; |
|
gap: 12px; |
|
align-items: center; |
|
flex-wrap: wrap; |
|
} |
|
|
|
#__cghr__solution-trigger { |
|
position: fixed; |
|
bottom: 20px; |
|
right: 20px; |
|
z-index: 100; |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
} |
|
|
|
#__cghr__solution-trigger .__cghr__indicator { |
|
font-size: 12px; |
|
opacity: 0.8; |
|
font-weight: normal; |
|
} |
|
|
|
.__cghr__try-prev, |
|
.__cghr__try-next, |
|
.__cghr__feedback { |
|
display: inline-block; |
|
color: #0969da; |
|
text-decoration: none; |
|
white-space: nowrap; |
|
} |
|
|
|
.__cghr__try-prev:hover, |
|
.__cghr__try-next:hover, |
|
.__cghr__feedback:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
.__cghr__try-prev { |
|
color: #586069; |
|
} |
|
`); |
|
|
|
const OCTICON_CHECK = |
|
'<svg class="octicon octicon-check" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M12 5l-8 8-4-4 1.5-1.5L4 10l6.5-6.5L12 5z"></path></svg>'; |
|
const OCTICON_HEART = |
|
'<svg class="octicon octicon-heart" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M9 2c-.97 0-1.69.42-2.2 1-.51.58-.78.92-.8 1-.02-.08-.28-.42-.8-1-.52-.58-1.17-1-2.2-1-1.632.086-2.954 1.333-3 3 0 .52.09 1.52.67 2.67C1.25 8.82 3.01 10.61 6 13c2.98-2.39 4.77-4.17 5.34-5.33C11.91 6.51 12 5.5 12 5c-.047-1.69-1.342-2.913-3-3z"></path></svg>'; |
|
|
|
const POSITIVE_REACTIONS = ["👍", "🎉", "❤️", "🚀"]; |
|
const NEGATIVE_REACTIONS = ["👎"]; |
|
const NEUTRAL_REACTIONS = ["😄", "😕", "👀"]; |
|
|
|
const REACTION_WEIGHTS = { |
|
positive: 1, |
|
negative: -1, |
|
neutral: 0.25, |
|
}; |
|
|
|
/** |
|
* Parse GitHub reactions from a comment container |
|
* @param {HTMLElement} commentContainer - The comment's parent container element |
|
* @returns {{total: number, counts: Object<string, number>}} Reaction totals and counts by emoji |
|
*/ |
|
function parseReactions(commentContainer) { |
|
const reactionsToolbar = commentContainer.querySelector( |
|
'[aria-label="Reactions"]' |
|
); |
|
if (!reactionsToolbar) { |
|
return { total: 0, counts: {} }; |
|
} |
|
|
|
const counts = {}; |
|
let total = 0; |
|
|
|
const reactionSwitches = |
|
reactionsToolbar.querySelectorAll('[role="switch"]'); |
|
|
|
reactionSwitches.forEach((switchEl) => { |
|
const ariaLabel = switchEl.getAttribute("aria-label"); |
|
if (!ariaLabel) return; |
|
|
|
const match = ariaLabel.match(/^(.+?)\s+(\d+)\s+reactions?/); |
|
if (match) { |
|
const emoji = match[1].trim(); |
|
const count = parseInt(match[2], 10); |
|
|
|
if (count > 0) { |
|
counts[emoji] = count; |
|
total += count; |
|
} |
|
} |
|
}); |
|
|
|
return { total, counts }; |
|
} |
|
|
|
/** |
|
* Calculate weighted score for a comment based on its reactions |
|
* @param {{total: number, counts: Object<string, number>}} reactions - Reaction data |
|
* @returns {number} Score between 0 and 1 (or negative if more negative reactions) |
|
*/ |
|
function calculateScore(reactions) { |
|
if (reactions.total === 0) return 0; |
|
|
|
let score = 0; |
|
|
|
POSITIVE_REACTIONS.forEach((emoji) => { |
|
score += (reactions.counts[emoji] || 0) * REACTION_WEIGHTS.positive; |
|
}); |
|
|
|
NEGATIVE_REACTIONS.forEach((emoji) => { |
|
score += (reactions.counts[emoji] || 0) * REACTION_WEIGHTS.negative; |
|
}); |
|
|
|
NEUTRAL_REACTIONS.forEach((emoji) => { |
|
score += (reactions.counts[emoji] || 0) * REACTION_WEIGHTS.neutral; |
|
}); |
|
|
|
return score / reactions.total; |
|
} |
|
|
|
/** |
|
* Get quality label based on comment score |
|
* @param {number} score - Calculated score from calculateScore() |
|
* @returns {"BAD" | "NEUTRAL" | "GOOD" | "GREAT"} Quality label |
|
*/ |
|
function getLabel(score) { |
|
if (score < 0.5) return "BAD"; |
|
if (score < 0.6) return "NEUTRAL"; |
|
if (score < 0.85) return "GOOD"; |
|
return "GREAT"; |
|
} |
|
|
|
/** |
|
* Parse a GitHub comment element and extract metadata |
|
* @param {HTMLElement} commentIdElement - Element with id="issuecomment-*" |
|
* @returns {{ |
|
* element: HTMLElement, |
|
* issuecommentIdElement: HTMLElement, |
|
* author: string, |
|
* date: number, |
|
* issuecommentId: string, |
|
* reactions: {total: number, counts: Object<string, number>}, |
|
* score: number, |
|
* label: string, |
|
* reactionsRelevant: boolean, |
|
* relativeStrength: number |
|
* }} Parsed comment data |
|
*/ |
|
function parseComment(commentIdElement) { |
|
const commentContainer = |
|
commentIdElement.closest(".react-issue-comment") || |
|
commentIdElement.parentElement?.closest(".react-issue-comment") || |
|
commentIdElement.parentElement; |
|
|
|
const authorLink = commentContainer.querySelector( |
|
'a[data-hovercard-type="user"]' |
|
); |
|
const author = authorLink?.textContent.trim() || "Unknown"; |
|
|
|
const timeElement = commentContainer.querySelector("relative-time"); |
|
const date = timeElement |
|
? Date.parse(timeElement.getAttribute("datetime")) |
|
: Date.now(); |
|
|
|
const issuecommentId = commentIdElement.id; |
|
|
|
const reactions = parseReactions(commentContainer); |
|
const score = calculateScore(reactions); |
|
const label = getLabel(score); |
|
|
|
return { |
|
element: commentContainer || commentIdElement, |
|
issuecommentIdElement: commentIdElement, |
|
author, |
|
date, |
|
issuecommentId, |
|
reactions, |
|
score, |
|
label, |
|
reactionsRelevant: reactions.total > 0, |
|
relativeStrength: 0, // Will be calculated later |
|
}; |
|
} |
|
|
|
/** |
|
* Analyze issue comments and highlight top solutions based on reactions |
|
*/ |
|
function analyzeSolutions() { |
|
console.log("[GitHub Solutions] Running analysis..."); |
|
|
|
document |
|
.querySelectorAll("#__cghr__solution-trigger, .__cghr__footnote") |
|
.forEach((el) => el.remove()); |
|
document |
|
.querySelectorAll( |
|
".__cghr__POSSIBLE, .__cghr__GREAT, .__cghr__GOOD, .__cghr__NEUTRAL, .__cghr__BAD" |
|
) |
|
.forEach((el) => { |
|
el.classList.remove( |
|
"__cghr__POSSIBLE", |
|
"__cghr__GREAT", |
|
"__cghr__GOOD", |
|
"__cghr__NEUTRAL", |
|
"__cghr__BAD" |
|
); |
|
}); |
|
|
|
const commentElements = document.querySelectorAll('[id^="issuecomment-"]'); |
|
|
|
if (commentElements.length === 0) { |
|
console.log("[GitHub Solutions] ❌ No comments found"); |
|
return; |
|
} |
|
|
|
console.log( |
|
`[GitHub Solutions] ✓ Found ${commentElements.length} comments` |
|
); |
|
|
|
const firstCommentId = commentElements[0]?.id; |
|
|
|
const allComments = []; |
|
commentElements.forEach((commentEl) => { |
|
const comment = parseComment(commentEl); |
|
allComments.push(comment); |
|
}); |
|
|
|
if (allComments.length === 0) { |
|
console.log("[GitHub Solutions] ❌ No valid comments parsed"); |
|
return; |
|
} |
|
|
|
const totalReactions = allComments.reduce( |
|
(sum, c) => sum + c.reactions.total, |
|
0 |
|
); |
|
const avgReactions = totalReactions / allComments.length; |
|
const minReactions = avgReactions * 0.75; |
|
|
|
console.log( |
|
`[GitHub Solutions] Average reactions: ${avgReactions.toFixed( |
|
1 |
|
)}, threshold: ${minReactions.toFixed(1)}` |
|
); |
|
|
|
const relevantComments = allComments.filter((comment) => { |
|
return ( |
|
comment.reactionsRelevant && |
|
comment.issuecommentId !== firstCommentId && |
|
comment.reactions.total >= minReactions && |
|
comment.score > 0 |
|
); |
|
}); |
|
|
|
console.log( |
|
`[GitHub Solutions] Found ${relevantComments.length} relevant comments` |
|
); |
|
|
|
if (relevantComments.length === 0) { |
|
console.log( |
|
"[GitHub Solutions] ❌ No relevant comments found (need comments with reactions above threshold)" |
|
); |
|
return; |
|
} |
|
|
|
relevantComments.forEach((comment) => { |
|
comment.element.classList.add("__cghr__POSSIBLE"); |
|
}); |
|
|
|
const maxScore = Math.max(...relevantComments.map((c) => c.score)); |
|
const maxReactions = Math.max( |
|
...relevantComments.map((c) => c.reactions.total) |
|
); |
|
|
|
relevantComments.forEach((comment) => { |
|
comment.relativeStrength = |
|
(comment.score * comment.reactions.total) / (maxScore * maxReactions); |
|
}); |
|
|
|
const sortedComments = relevantComments.sort((a, b) => { |
|
if (b.reactions.total !== a.reactions.total) { |
|
return b.reactions.total - a.reactions.total; |
|
} |
|
if (b.relativeStrength !== a.relativeStrength) { |
|
return b.relativeStrength - a.relativeStrength; |
|
} |
|
return a.date - b.date; |
|
}); |
|
|
|
const solutions = sortedComments.slice(0, 3); |
|
|
|
if (solutions.length === 0) return; |
|
|
|
let currentSolutionIndex = 0; |
|
|
|
const jumpButton = document.createElement("button"); |
|
jumpButton.className = "btn btn-primary"; |
|
jumpButton.id = "__cghr__solution-trigger"; |
|
|
|
const updateButton = () => { |
|
const current = solutions[currentSolutionIndex]; |
|
const indicator = |
|
solutions.length > 1 |
|
? `<span class="__cghr__indicator">(${currentSolutionIndex + 1}/${ |
|
solutions.length |
|
})</span>` |
|
: ""; |
|
jumpButton.innerHTML = `${OCTICON_CHECK} Jump to solution ${indicator}`; |
|
}; |
|
|
|
jumpButton.addEventListener("click", (e) => { |
|
e.preventDefault(); |
|
|
|
const current = solutions[currentSolutionIndex]; |
|
window.location.hash = current.issuecommentId; |
|
|
|
if (solutions.length > 1) { |
|
currentSolutionIndex = (currentSolutionIndex + 1) % solutions.length; |
|
updateButton(); |
|
} |
|
}); |
|
|
|
updateButton(); |
|
document.body.appendChild(jumpButton); |
|
|
|
solutions.forEach((solution, index) => { |
|
const prevSolution = solutions[index - 1]; |
|
const nextSolution = solutions[index + 1]; |
|
|
|
const rating = |
|
index === 0 ? "Best" : index === 1 ? "Second best" : "Third best"; |
|
|
|
const prevBtn = prevSolution |
|
? `<a class="__cghr__jump-to __cghr__try-prev" href="#${prevSolution.issuecommentId}">Previous</a>` |
|
: ""; |
|
|
|
const nextBtn = nextSolution |
|
? `<a class="__cghr__jump-to __cghr__try-next" href="#${nextSolution.issuecommentId}">Try another one?</a>` |
|
: ""; |
|
|
|
const feedbackSubject = encodeURIComponent( |
|
"GitHub Issues Solutions - Feedback" |
|
); |
|
const feedbackBody = encodeURIComponent( |
|
`Hi!\n\nI'm reviewing the solution at: ${window.location.href}#${solution.issuecommentId}\n\nHere's my feedback:\n\n` |
|
); |
|
const feedbackBtn = `<a class="__cghr__feedback" href="mailto:?subject=${feedbackSubject}&body=${feedbackBody}">Feedback</a>`; |
|
|
|
solution.element.classList.add(`__cghr__${solution.label}`); |
|
|
|
const existingFootnote = |
|
solution.element.parentElement?.querySelector(".__cghr__footnote"); |
|
existingFootnote?.remove(); |
|
|
|
const footnote = document.createElement("div"); |
|
footnote.className = "__cghr__footnote"; |
|
footnote.innerHTML = ` |
|
<div class="__cghr__description"> |
|
${OCTICON_HEART} ${rating} rated comment - |
|
<span class="State State--green State--small ml-1">possible solution</span> |
|
</div> |
|
<div class="__cghr__actions"> |
|
${prevBtn} |
|
${nextBtn} |
|
${feedbackBtn} |
|
</div> |
|
`; |
|
|
|
solution.element.parentElement.insertBefore( |
|
footnote, |
|
solution.element.nextSibling |
|
); |
|
}); |
|
|
|
console.log(`[GitHub Solutions] ✅ Found ${solutions.length} solution(s)!`); |
|
solutions.forEach((sol, idx) => { |
|
console.log( |
|
` ${idx + 1}. Comment by ${sol.author} - ${ |
|
sol.reactions.total |
|
} reactions, score: ${sol.score.toFixed(2)}` |
|
); |
|
}); |
|
} |
|
|
|
if (document.readyState === "loading") { |
|
document.addEventListener("DOMContentLoaded", analyzeSolutions); |
|
} else { |
|
setTimeout(analyzeSolutions, 1000); |
|
} |
|
|
|
const observer = new MutationObserver((mutations) => { |
|
for (const mutation of mutations) { |
|
if (mutation.addedNodes.length > 0) { |
|
const hasNewComments = Array.from(mutation.addedNodes).some((node) => { |
|
return ( |
|
node.nodeType === 1 && |
|
(node.id?.startsWith("issuecomment-") || |
|
node.querySelector?.('[id^="issuecomment-"]')) |
|
); |
|
}); |
|
|
|
if (hasNewComments) { |
|
clearTimeout(observer.timeout); |
|
observer.timeout = setTimeout(analyzeSolutions, 1000); |
|
} |
|
} |
|
} |
|
}); |
|
|
|
const main = document.querySelector("main"); |
|
if (main) { |
|
observer.observe(main, { |
|
childList: true, |
|
subtree: true, |
|
}); |
|
} |
|
})(); |