GitHub Issue Triage Helper

Suggest triage questions for GitHub issues using AI

// ==UserScript==
// @name         GitHub Issue Triage Helper
// @namespace    https://github.com/nbolton/github-triage-helper
// @source       https://github.com/nbolton/github-triage-helper
// @license      MIT
// @version      0.6
// @description  Suggest triage questions for GitHub issues using AI
// @author       nbolton
// @match        https://github.com/*
// @connect      api.openai.com
// @connect      api.github.com
// @grant        GM_xmlhttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @require      https://update.gf.qytechs.cn/scripts/34138/223779/markedjs.js
// ==/UserScript==

const css =
`
#ai-suggestions-box {
    margin: 16px 0 0 55px;
    padding: 12px 16px;
    border: 1px solid #30363d;
    border-radius: 6px;
    line-height: 1.5;
    font-size: 14px;
}

#ai-suggestions-box ol {
    margin-left: 20px;
    padding-left: 0;
}

#ai-suggestions-box li {
    margin-bottom: 6px;
}

#ai-suggestions-box h3 {
    margin-bottom: 15px;
}
`;

// Remember: Secrets be reset/edited on the script's 'Storage' tab in Tampermonkey (when using advanced config mode).
(function () {
    'use strict';

    const logger = createLogger("triage-helper");
    let apiKey = null;
    let githubToken = null;
    let box = null;

    async function init() {
        logger.log("GitHub Issue Triage Helper");

        apiKey = await GM.getValue("openai_api_key");
        if (!apiKey) {
            apiKey = prompt("OpenAI API key:");
            if (apiKey) {
                await GM.setValue("openai_api_key", apiKey);
            }
        }

        githubToken = await GM.getValue("github_token");
        if (!githubToken) {
            githubToken = prompt("GitHub API token:");
            if (githubToken) {
                await GM.setValue("github_token", githubToken);
            }
        }

        let lastUrl = location.href;
        const observer = new MutationObserver(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                logger.debug("URL changed:", lastUrl);
                run();
                return;
            }

            if (!isValidUrl(location.href)) {
                logger.debug("Ignoring:", location.href);
                return;
            }

            // prevent recursion
            if (document.getElementById('ai-suggestions-box')) return;

            logger.debug("DOM changed, injecting suggestion box");

            box = injectSuggestionBox();
            if (!box) {
                logger.debug("No where to inject suggestion box");
                return;
            }

            box.innerHTML = "Loading AI suggestions...";
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        run();
    }

    async function getIssueContext() {
        const pathMatch = window.location.pathname.match(/^\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
        if (!pathMatch) return null;

        const [, owner, repo, issueNumber] = pathMatch;
        return { owner, repo, issueNumber };
    }

    async function fetchIssueText(githubToken) {
        logger.log("Fetching issue text...");

        const context = await getIssueContext();
        if (!context) throw new Error("Invalid GitHub URL");

        logger.debug("Issue number:", context.issueNumber);

        const { owner, repo, issueNumber } = context;

        const headers = {
            'Accept': 'application/vnd.github+json',
            'Authorization': `token ${githubToken}`,
            'User-Agent': 'GitHub-Issue-Triage-Script'
        };

        const fetchWithGM = (url, timeoutMs = 5000) => {
            return Promise.race([
                new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url,
                        headers,
                        onload: (res) => {
                            if (res.status !== 200) return reject(`GitHub API error ${res.status} for ${url}`);
                            try {
                                resolve(JSON.parse(res.responseText));
                            } catch (e) {
                                reject(`Failed to parse JSON from ${url}`);
                            }
                        },
                        onerror: () => reject(`Network error for ${url}`)
                    });
                }),
                new Promise((_, reject) => setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs))
            ])
        }

        const readmeUrl = `https://api.github.com/repos/${owner}/${repo}/readme`;
        const issueUrl = `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`;
        const commentsUrl = `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`;

        const [readme, issue, comments] = await Promise.all([
            fetchWithGM(readmeUrl),
            fetchWithGM(issueUrl),
            fetchWithGM(commentsUrl)
        ]);

        const readmeDecoded = atob(readme.content || '');
        logger.debug("GitHub response:", readmeDecoded, issue, comments);

        const allText = [
            `GitHub repo: https://github.com/${owner}/${repo}`,
            "#Readme\n\n```markdown\n" + readmeDecoded + "\n```",
            `#Issue\n\nUser: @${issue.user.login}\nTitle: ${issue.title}\nBody:\n${issue.body}`,
            ...comments.map(c => `@${c.user.login}:\n${c.body}`)
        ].join('\n\n---\n\n');

        return allText;
    }

    async function fetchAISuggestions(commentsText, apiKey) {
        logger.log("Fetching AI suggestions...");

        const payload = JSON.stringify({
            model: "gpt-4o",
            messages: [
                {
                    role: "system",
                    content: (
                        "You are a senior GitHub issue triage assistant. " +
                        "Your job is to help maintainers understand, reproduce, and fix bugs by asking the most useful clarifying questions. " +
                        "Focus on uncovering missing information, narrowing scope, and identifying blockers to resolution. " +
                        "Avoid repeating what has already been said. " +
                        "Format responses in Markdown."
                    )
                },
                {
                    role: "user",
                    content: (
                        `Here is a GitHub issue thread including the original report and comments:\n\n${commentsText}\n\n` +
                        `Your task:\n` +
                        `1. Summarize the current understanding of the issue in one short paragraph.\n` +
                        `2. Then, list up to 5 concise, helpful questions that would help clarify, reproduce, or scope the issue further.\n\n` +
                        `Use clear, technical language and avoid redundancy.`
                    )
                }
            ],
            temperature: 0.3, // Lower numbers give safer, less creative answers.
            max_tokens: 600
        });

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: 'https://api.openai.com/v1/chat/completions',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${apiKey}`
                },
                data: payload,
                onload: function (response) {
                    try {
                        const json = JSON.parse(response.responseText);
                        logger.debug("AI response:", json);
                        const content = json.choices?.[0]?.message?.content || 'No response';
                        resolve(content);
                    } catch (e) {
                        reject('Failed to parse AI response');
                    }
                },
                onerror: function () {
                    reject('Failed to reach AI server');
                }
            });
        });
    }

    function injectSuggestionBox(content) {
        const timeline = document.querySelector('[class*="Timeline-Timeline"]');

        if (!timeline) {
            return null;
        }

        const style = document.createElement('style');
        style.textContent = css;
        document.head.appendChild(style);

        const box = document.createElement('div');
        box.id = 'ai-suggestions-box';
        timeline.parentNode.insertBefore(box, timeline.nextSibling);

        return box;
    }

    function isValidUrl(url) {
        if (/\/issues\/\d+\?notification_referrer_id.+/.test(url)) {
            // Page loads twice when URL contains 'notification_referrer_id', so ignore this.
            logger.debug("URL has 'notification_referrer_id'");
            return false;
        }

        if(!/\/issues\/\d+/.test(url)) {
            return false;
        }

        return true;
    }

    async function run() {

        logger.log("Running");

        if (!isValidUrl(location.href)) {
            logger.debug("Ignoring:", location.href);
            return;
        }

        const aiInput = await fetchIssueText(githubToken);
        logger.log("AI input length:", aiInput.length);
        logger.debug("AI input:", aiInput);

        const aiSuggestions = await fetchAISuggestions(aiInput, apiKey);
        logger.log("AI response length:", aiSuggestions.length);
        logger.debug("AI suggestions:", aiSuggestions);

        if (!box) {
            // TODO: delay rendering until it is loaded
            logger.error("Suggestions box didn't load in time");
            return;
        }

        const html = marked.parse(aiSuggestions);
        box.innerHTML = html;
    }

    function createLogger(scriptName) {
        function formatMessage(level, ...args) {
            const prefix = `[${scriptName}]`;
            return [prefix, ...args];
        }

        return {
            log: (...args) => console.log(...formatMessage('log', ...args)),
            info: (...args) => console.info(...formatMessage('info', ...args)),
            warn: (...args) => console.warn(...formatMessage('warn', ...args)),
            error: (...args) => console.error(...formatMessage('error', ...args)),
            debug: (...args) => console.debug(...formatMessage('debug', ...args)),
        };
    }

    init();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址