Skip to content

Instantly share code, notes, and snippets.

@bengry
Last active October 5, 2025 09:13
Show Gist options
  • Select an option

  • Save bengry/eb0f4a62d2194c9eccac737d0bdc05bd to your computer and use it in GitHub Desktop.

Select an option

Save bengry/eb0f4a62d2194c9eccac737d0bdc05bd to your computer and use it in GitHub Desktop.
GitHub Issues Instant Solutions - UserScript to find best solutions in GitHub issues by analyzing reactions

GitHub Issues Instant Solutions - UserScript

Automatically find the best solution to a GitHub Issue by analyzing comment reactions.

Features

  • 🎯 Automatic Solution Detection - Analyzes reactions on comments to identify the most helpful solutions
  • 🚀 Quick Navigation - Adds a floating "Jump to solution" button to instantly navigate to the best answer
  • 🏆 Top 3 Solutions - Highlights and ranks the top 3 most helpful comments
  • Fast & Lightweight - Pure vanilla JavaScript, no dependencies
  • 🔄 Real-time Updates - Automatically re-analyzes when new comments are added

How It Works

The script uses a scoring algorithm based on GitHub reaction types:

  • Positive reactions (👍 🎉 ❤️ 🚀): +1.0 weight
  • Negative reactions (👎): -1.0 weight
  • Neutral reactions (😄 😕 👀): +0.25 weight

Comments are scored and ranked by:

  1. Total reaction count (higher is better)
  2. Relative strength (score × reactions)
  3. Date (older solutions preferred when tied)

Only comments with reactions above 75% of the average are considered.

Installation

  1. Install a userscript manager:

  2. Click here to install: github-issues-solutions.user.js

  3. Your userscript manager will prompt you to install it

  4. Visit any GitHub issue page and see the magic! ✨

Usage

  1. Navigate to any GitHub issue page (e.g., https://github.com/facebook/react/issues/12345)
  2. If the script finds potential solutions, you'll see:
    • A floating "Jump to solution" button in the bottom-right corner
    • Highlighted comments marked as "Best", "Second best", or "Third best" rated solution
    • Navigation buttons to move between solutions

Examples

Try it on these popular issues:

Compatibility

  • ✅ GitHub.com
  • ✅ Chrome, Firefox, Safari, Edge, Opera
  • ✅ Works with GitHub's dynamic page loading

Credits

Created by bengry with original idea by Martin Galovic (2017 Chrome extension).

Modern implementation features:

  • Native browser APIs (no jQuery/Underscore dependencies)
  • Modern ES6+ syntax
  • MutationObserver for dynamic content
  • Improved performance and maintainability

License

MIT License - Feel free to modify and share!

Changelog

v2.2.0 (October 2025) - Cycling Button

  • 🔄 Cycling "Jump to solution" button - Click to cycle through all solutions
  • 📊 Solution indicator - Shows (1/3), (2/3), etc. when multiple solutions exist
  • ✨ Improved button interactivity and user experience

v2.1.0 (October 2025) - 2025 GitHub Rewrite

  • 🔥 MAJOR REWRITE - Updated for modern GitHub DOM structure (2025)
  • ✅ Fixed broken selectors from 2017 GitHub
  • 🎯 Now uses [id^="issuecomment-"] and [role="switch"] selectors
  • ⚡ Works with GitHub's current reaction system
  • 🐛 Fixed MutationObserver to detect new comments properly
  • 🔍 Enhanced debug logging

v2.0.0 (October 2025)

  • 🎉 Converted from Chrome extension to userscript
  • ⚡ Removed jQuery and Underscore.js dependencies
  • 🔄 Modern vanilla JavaScript with ES6+ features
  • 🎨 Improved code organization and maintainability

v1.0.5 (2017 - Original)

  • Chrome extension release by Martin Galovic
  • Note: Extension broken due to GitHub DOM changes
// ==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,
});
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment