GitHub Release Note Generator from Commits

Adds a button on the GitHub new release page to generate release notes with custom formats, dark mode support, and more commit details.

// ==UserScript==
// @name         GitHub Release Note Generator from Commits
// @name:zh-CN   GitHub 基于 Commit 生成 Release Note
// @namespace    https://microblock.cc/
// @version      1.0.0
// @description  Adds a button on the GitHub new release page to generate release notes with custom formats, dark mode support, and more commit details.
// @description:zh-CN 在 GitHub 的 new release 页面添加一个按钮,用于基于 commits 生成发布说明。支持自定义格式、暗色模式及更多提交详情。
// @author       MicroBlock
// @match        https://github.com/*/*/releases/new
// @grant        GM_setValue
// @grant        GM_getValue
// @license      AGPL-3.0
// ==/UserScript==

(function () {
    'use strict';

    const CONVENTIONAL_COMMIT_REGEX = /^(?<type>\w+)(?:\((?<scope>[^)]+)\))?!?:\s*(?<subject>.+)/;

    const CONFIG_KEYS = {
        githubToken: 'grn_github_token',
        groupBy: 'grn_group_by',
        templatePreset: 'grn_template_preset',
        customFormat: 'grn_custom_format',
        useGeneralScope: 'grn_use_general_scope'
    };

    function saveConfig(key, value) {
        GM_setValue(CONFIG_KEYS[key], value);
    }

    function loadConfig(key, defaultValue) {
        return GM_getValue(CONFIG_KEYS[key], defaultValue);
    }
    function injectStyles() {
        const style = document.createElement('style');
        style.textContent = `
            .grn-modal-overlay {
                position: fixed;
                top: 0;
                left: 0;
                right: 0;
                bottom: 0;
                background: rgba(0, 0, 0, 0.5);
                z-index: 9999;
                display: flex;
                align-items: flex-start;
                justify-content: center;
                padding-top: 5vh;
            }
            .grn-modal-content {
                background: #fff; 
                border: 1px solid #d0d7de; 
                border-radius: 6px;
                padding: 20px;
                width: 90%;
                max-width: 800px;
                max-height: 90vh;
                display: flex;
                flex-direction: column;
                gap: 15px;
                box-shadow: 0 8px 24px rgba(0,0,0,0.12); 
            }
            .grn-modal-header {
                display: flex;
                justify-content: space-between;
                align-items: center;
                border-bottom: 1px solid #e1e4e8; 
                padding-bottom: 10px;
            }
            .grn-modal-header h2 {
                font-size: 1.5em;
                margin: 0;
                color: #24292f; 
            }
            .grn-modal-close-btn {
                background: none;
                border: none;
                font-size: 1.5em;
                cursor: pointer;
                color: #57606a; 
            }
            .grn-modal-body {
                display: flex;
                flex-direction: column;
                gap: 20px;
                overflow-y: auto;
                color: #24292f; 
            }
            .grn-options {
                display: grid;
                grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
                gap: 15px;
            }
            .grn-options fieldset {
                border: 1px solid #d0d7de; 
                padding: 10px 15px;
                border-radius: 6px;
                display: flex;
                flex-direction: column;
                gap: 8px;
            }
            .grn-options legend {
                font-weight: 600;
                padding: 0 5px;
                color: #24292f; 
            }
            .grn-custom-format-container, .grn-custom-format-container label {
                display: flex;
                flex-direction: column;
                gap: 5px;
            }
            .grn-custom-format-input-wrapper {
                display: flex;
                align-items: center;
                gap: 8px;
            }
            #grn-custom-format-input {
                 width: 100%;
                 background-color: #f6f8fa; 
                 border: 1px solid #d0d7de; 
                 border-radius: 6px;
                 padding: 5px 12px;
                 font-family: var(--font-family-monospace, monospace);
                 color: #24292f; 
                 resize: vertical;
                 min-height: 38px;
            }
            .grn-info-icon {
                color: #57606a; 
                cursor: help;
                font-weight: bold;
                font-size: 1.1em;
            }
            .grn-output {
                display: flex;
                flex-direction: column;
                gap: 10px;
                flex-grow: 1;
                min-height: 200px;
            }
            #grn-changelog-output {
                width: 100%;
                flex-grow: 1;
                resize: vertical;
                min-height: 200px;
                font-family: var(--font-family-monospace, monospace);
                background-color: #f6f8fa; 
                color: #24292f; 
                border: 1px solid #d0d7de; 
                border-radius: 6px;
                padding: 8px;
            }
            .Button.grn-copy-btn {
                align-self: flex-end;
                margin-top: 5px;
            }
            .grn-info {
                font-size: 0.9em;
                color: #57606a; 
                background-color: #f6f8fa; 
                padding: 8px 12px;
                border-radius: 6px;
            }
            
            html[data-color-mode="dark"] .grn-modal-content {
                background: #161b22; 
                border-color: #30363d; 
                box-shadow: 0 8px 24px rgba(0,0,0,0.6); 
            }
            html[data-color-mode="dark"] .grn-modal-header {
                border-bottom-color: #30363d;
            }
            html[data-color-mode="dark"] .grn-modal-header h2 {
                color: #c9d1d9; 
            }
            html[data-color-mode="dark"] .grn-modal-close-btn {
                color: #8b949e; 
            }
            html[data-color-mode="dark"] .grn-modal-body {
                color: #c9d1d9;
            }
            html[data-color-mode="dark"] .grn-options fieldset {
                border-color: #30363d;
            }
            html[data-color-mode="dark"] .grn-options legend {
                color: #c9d1d9;
            }
            html[data-color-mode="dark"] #grn-custom-format-input,
            html[data-color-mode="dark"] #grn-changelog-output {
                background-color: #0d1117; 
                border-color: #30363d;
                color: #c9d1d9;
            }
            html[data-color-mode="dark"] .grn-info-icon {
                color: #8b949e;
            }
            html[data-color-mode="dark"] .grn-info {
                color: #c9d1d9;
                background-color: #1f2a36; 
            }
        `;
        document.head.appendChild(style);
    }

    /**
     * 在GitHub原生的“Generate release notes”按钮旁边创建一个自定义按钮。
     */
    function createGeneratorButton() {

        const originalBtn = document.getElementById('generate-notes-btn');

        if (!originalBtn || document.getElementById('custom-generate-btn')) {
            return;
        }

        const newBtn = originalBtn.cloneNode(true);
        newBtn.id = 'custom-generate-btn';
        newBtn.ariaDisabled = false
        newBtn.querySelector('.Button-label').textContent = '基于 Commit 生成 Release Note';
        newBtn.removeAttribute('data-hotkey');

        newBtn.classList.add('Button--primary');
        newBtn.classList.remove('Button--secondary');
        originalBtn.classList.add('Button--secondary');
        originalBtn.classList.remove('Button--primary');

        newBtn.addEventListener('click', onGenerateClick);

        originalBtn.insertAdjacentElement('afterend', newBtn);
    }

    let currentRepoOwner = '';
    let currentRepoName = '';

    async function ghFetch(endpoint) {
        const url = `https://api.github.com${endpoint}`;
        const headers = {
            'Accept': 'application/vnd.github.v3+json',
        };
        const token = loadConfig('githubToken', '');
        if (token) {
            headers['Authorization'] = `token ${token}`;
        }
        const response = await fetch(url, { headers });
        if (!response.ok) {
            const errorData = await response.json();
            throw new Error(`GitHub API Error: ${response.status} - ${errorData.message || 'Unknown error'}`);
        }
        return response.json();
    }

    async function getRepoInfo(owner, repo) {
        return ghFetch(`/repos/${owner}/${repo}`);
    }

    async function getLatestReleaseInfo(owner, repo) {
        try {
            const latestRelease = await ghFetch(`/repos/${owner}/${repo}/releases/latest`);
            const tagName = latestRelease.tag_name;

            const refData = await ghFetch(`/repos/${owner}/${repo}/git/ref/tags/${tagName}`);
            const commitSha = refData.object.type === 'tag'
                ? (await ghFetch(refData.object.url.replace('https://api.github.com', ''))).object.sha
                : refData.object.sha;
            return { tagName, commitSha };
        } catch (error) {

            if (error instanceof Error && String(error).includes('404')) {
                return null;
            }
            throw error;
        }
    }

    async function getCommitsSince(owner, repo, branch, baseCommitSha) {
        let commits = [];
        let page = 1;
        let foundBaseCommit = !baseCommitSha;

        while (true) {
            const pageCommits = await ghFetch(`/repos/${owner}/${repo}/commits?sha=${branch}&per_page=100&page=${page}`);
            if (pageCommits.length === 0) break;

            for (const commit of pageCommits) {
                if (commit.sha === baseCommitSha) {
                    foundBaseCommit = true;
                    break;
                }
                commits.push(commit);
            }
            if (foundBaseCommit) break;
            page++;
        }
        return commits;
    }
    function parseCommits(rawCommits) {
        const repoUrl = `https://github.com/${currentRepoOwner}/${currentRepoName}`;
        return rawCommits.map(c => {
            const message = c.commit.message.split('\n')[0];
            const match = message.match(CONVENTIONAL_COMMIT_REGEX);
            const coAuthors = c.commit.message.split('\n')
                .filter(line => line.startsWith('Co-authored-by:'))
                .map(line => line.replace('Co-authored-by:', '').trim());

            return {
                sha: c.sha.slice(0, 7),
                fullSha: c.sha,
                type: match ? match.groups.type.toLowerCase() : 'other',
                scope: (match && match.groups.scope) || null,
                subject: match ? match.groups.subject : message,
                committer: c.commit.committer.name,
                commitUrl: `${repoUrl}/commit/${c.sha}`,
                coAuthors: coAuthors,
            };
        });
    }

    function generateChangelog(parsedCommits, options) {
        const typeHeadings = {
            feat: '✨ Features',
            fix: '🐛 Bug Fixes',
            docs: '📝 Documentation',
            style: '🎨 Styles',
            refactor: '♻️ Code Refactoring',
            perf: '⚡ Performance Improvements',
            test: '✅ Tests',
            build: '📦 Build System',
            ci: '🚀 Continuous Integration',
            chore: '🧹 Chores',
            revert: '⏪ Reverts',
            other: '📚 Miscellaneous',
        };

        let sortedCommits = [...parsedCommits];
        if (options.groupBy === 'type') {
            const typeOrder = Object.keys(typeHeadings);
            sortedCommits.sort((a, b) => {
                const typeA = typeOrder.indexOf(a.type);
                const typeB = typeOrder.indexOf(b.type);
                if (typeA !== typeB) return typeA - typeB;
                return (a.scope || '').localeCompare(b.scope || '');
            });
        }
        const formatLine = (commit) => {
            let displayScope = commit.scope;
            if (!displayScope && options.useGeneralScope) {
                displayScope = 'general';
            }
            const scopeFormatted = displayScope ? `(${displayScope})` : '';
            const commitLink = `[${commit.sha}](${commit.commitUrl})`;
            const fullCommitLink = `[${commit.fullSha}](${commit.commitUrl})`;
            const coAuthorsFormatted = commit.coAuthors.length > 0 ? ` (Co-authored-by: ${commit.coAuthors.join(', ')})` : '';

            return options.customFormat
                .replace(/\$\{type\}/g, commit.type)
                .replace(/\$\{scope\}/g, displayScope || '')
                .replace(/\$\{scope_formatted\}/g, scopeFormatted)
                .replace(/\$\{subject\}/g, commit.subject)
                .replace(/\$\{sha\}/g, commit.sha)
                .replace(/\$\{fullSha\}/g, commit.fullSha)
                .replace(/\$\{committer\}/g, commit.committer)
                .replace(/\$\{commitUrl\}/g, commit.commitUrl)
                .replace(/\$\{commitLink\}/g, commitLink)
                .replace(/\$\{fullCommitLink\}/g, fullCommitLink)
                .replace(/\$\{coAuthors\}/g, coAuthorsFormatted);
        };

        if (options.groupBy === 'type') {
            const groups = sortedCommits.reduce((acc, commit) => {
                (acc[commit.type] = acc[commit.type] || []).push(commit);
                return acc;
            }, {});
            return Object.keys(typeHeadings)
                .filter(type => groups[type] && groups[type].length > 0)
                .map(type => {
                    const heading = `### ${typeHeadings[type]}`;
                    const list = groups[type].map(formatLine).join('\n');
                    return `${heading}\n${list}`;
                })
                .join('\n\n');
        } else {

            return sortedCommits.map(formatLine).join('\n');
        }
    }

    function showChangelogModal(commits, latestReleaseInfo, branch) {
        const modalId = 'grn-modal';
        if (document.getElementById(modalId)) return;

        const parsedCommits = parseCommits(commits);
        const fromInfo = latestReleaseInfo
            ? `从 \`${latestReleaseInfo.tagName}\` 到 \`${branch}\` 分支的 \`HEAD\``
            : `来自 \`${branch}\` 分支的所有可达 commits`;

        const variablesTooltip = `可用变量 (在自定义格式中使用,例如 \`- \${type}: \${subject} \${commitLink}\`):
- \`\${type}\`: 类型 (e.g., feat, fix)
- \`\${scope}\`: 范围 (e.g., api, core)
- \`\${scope_formatted}\`: 带括号的范围 (e.g., (api)),无则为空
- \`\${subject}\`: 提交信息主题
- \`\${sha}\`: 7位短 commit hash
- \`\${fullSha}\`: 完整 commit hash
- \`\${committer}\`: 提交者名称
- \`\${commitUrl}\`: commit 的 GitHub 链接 (URL)
- \`\${commitLink}\`: commit 的 GitHub 链接 (Markdown格式: [sha](url))
- \`\${fullCommitLink}\`: commit 的 GitHub 链接 (Markdown格式: [fullSha](url))
- \`\${coAuthors}\`: 共同作者 (如果存在, 例如 "(Co-authored-by: dev1, dev2)")`;

        const overlay = document.createElement('div');
        overlay.id = modalId;
        overlay.className = 'grn-modal-overlay';
        overlay.innerHTML = `
            <div class="grn-modal-content">
                <div class="grn-modal-header">
                    <h2>生成 Release Note</h2>
                    <button class="grn-modal-close-btn">&times;</button>
                </div>
                <div class="grn-modal-body">
                    <div class="grn-info">
                        在默认分支 <strong>${branch}</strong> 上找到了 ${commits.length} 个新 commits (${fromInfo}).
                    </div>
                    <div class="grn-options">
                        <fieldset>
                            <legend>Settings</legend>
                            <label>GitHub Token: <input type="password" id="grn-github-token" placeholder="Optional, for higher rate limits"></label>
                        </fieldset>

                        <fieldset>
                            <legend>分组和排序</legend>
                            <label><input type="radio" name="groupBy" value="type" checked> 按类型分组 (feat, fix...)</label>
                            <label><input type="radio" name="groupBy" value="time"> 按提交时间排序 (默认获取顺序)</label>
                        </fieldset>

                        <fieldset>
                            <legend>格式预设</legend>
                            <label><input type="radio" name="templatePreset" value="markdown-header" checked> 推荐样式 (按类型分组)</label>
                            <label><input type="radio" name="templatePreset" value="flat-list"> 扁平列表 (按时间排序)</label>
                        </fieldset>

                        <fieldset>
                            <legend>自定义格式选项</legend>
                             <div class="grn-custom-format-container">
                                <label for="grn-custom-format-input">自定义格式字符串:</label>
                                <div class="grn-custom-format-input-wrapper">
                                     <textarea id="grn-custom-format-input" rows="2"></textarea>
                                     <span class="grn-info-icon" title="${variablesTooltip}">ⓘ</span>
                                </div>
                            </div>
                            <label><input type="checkbox" id="grn-toggle-general-scope" checked> 为无 Scope 的 Commit 添加 (general) 标识</label>
                        </fieldset>
                    </div>

                    <div class="grn-output">
                        <textarea id="grn-changelog-output" readonly></textarea>
                        <button id="grn-copy-btn" class="Button Button--primary grn-copy-btn">复制到剪贴板</button>
                    </div>
                </div>
            </div>
        `;
        document.body.appendChild(overlay);

        const outputArea = document.getElementById('grn-changelog-output');
        const optionsForm = overlay.querySelector('.grn-options');
        const customFormatInput = document.getElementById('grn-custom-format-input');
        const tokenInput = document.getElementById('grn-github-token');
        tokenInput.value = loadConfig('githubToken', '');
        tokenInput.addEventListener('input', () => saveConfig('githubToken', tokenInput.value));

        const groupByValue = loadConfig('groupBy', 'type');
        optionsForm.querySelector(`input[name="groupBy"][value="${groupByValue}"]`).checked = true;

        const templatePresetValue = loadConfig('templatePreset', 'markdown-header');
        optionsForm.querySelector(`input[name="templatePreset"][value="${templatePresetValue}"]`).checked = true;

        const useGeneralScopeValue = loadConfig('useGeneralScope', true);
        document.getElementById('grn-toggle-general-scope').checked = useGeneralScopeValue;
        const presetFormats = {
            'markdown-header': '- **${scope_formatted}** ${subject} by ${committer} ${commitLink}${coAuthors}',
            'flat-list': '- ${type}${scope_formatted}: ${subject} by ${committer} ${commitLink}${coAuthors}',
        };
        customFormatInput.value = loadConfig('customFormat', presetFormats[templatePresetValue]);
        function applyPreset() {
            const selectedPreset = optionsForm.querySelector('input[name="templatePreset"]:checked').value;
            customFormatInput.value = presetFormats[selectedPreset];
            saveConfig('customFormat', customFormatInput.value);
        }

        function updateChangelog() {
            const options = {
                groupBy: optionsForm.querySelector('input[name="groupBy"]:checked').value,
                customFormat: customFormatInput.value,
                useGeneralScope: document.getElementById('grn-toggle-general-scope').checked,
            };
            outputArea.value = generateChangelog(parsedCommits, options);
        }
        optionsForm.addEventListener('change', (e) => {
            if (e.target.name === 'templatePreset') {
                applyPreset();
                saveConfig('templatePreset', e.target.value);

                if (e.target.value === 'markdown-header') {
                    optionsForm.querySelector('input[name="groupBy"][value="type"]').checked = true;
                    saveConfig('groupBy', 'type');
                } else if (e.target.value === 'flat-list') {
                    optionsForm.querySelector('input[name="groupBy"][value="time"]').checked = true;
                    saveConfig('groupBy', 'time');
                }
            } else if (e.target.name === 'groupBy') {
                saveConfig('groupBy', e.target.value);
            }
            updateChangelog();
        });
        customFormatInput.addEventListener('input', () => {
            saveConfig('customFormat', customFormatInput.value);
            updateChangelog();
        });

        document.getElementById('grn-toggle-general-scope').addEventListener('change', (e) => {
            saveConfig('useGeneralScope', e.target.checked);
            updateChangelog();
        });
        overlay.querySelector('.grn-modal-close-btn').addEventListener('click', () => overlay.remove());
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) {
                overlay.remove();
            }
        });
        document.getElementById('grn-copy-btn').addEventListener('click', (e) => {
            navigator.clipboard.writeText(outputArea.value).then(() => {
                const originalText = e.target.textContent;
                e.target.textContent = '已复制!';
                setTimeout(() => { e.target.textContent = originalText; }, 2000);
            }).catch(err => {
                console.error('复制失败:', err);
                alert('复制失败,请手动复制。');
            });
        });
        applyPreset();
        updateChangelog();
    }
    async function onGenerateClick(event) {
        event.preventDefault();
        const button = event.currentTarget;
        const buttonLabel = button.querySelector('.Button-label');
        const originalText = buttonLabel.textContent;

        button.disabled = true;
        buttonLabel.textContent = '正在获取 Commits...';

        try {

            const pathMatch = window.location.pathname.match(/\/([^/]+)\/([^/]+)\/releases\/new/);
            if (!pathMatch || pathMatch.length < 3) {
                throw new Error("无法从URL解析仓库信息。");
            }
            const [, owner, repo] = pathMatch;
            currentRepoOwner = owner;
            currentRepoName = repo;

            const repoInfo = await getRepoInfo(owner, repo);
            const branch = repoInfo.default_branch;

            const latestReleaseInfo = await getLatestReleaseInfo(owner, repo);
            const baseCommitSha = latestReleaseInfo ? latestReleaseInfo.commitSha : null;

            const commits = await getCommitsSince(owner, repo, branch, baseCommitSha);

            if (commits.length > 0) {
                showChangelogModal(commits, latestReleaseInfo, branch);
            } else {
                alert(`在默认分支 "${branch}" 上,自上次发布版本${latestReleaseInfo ? ` (\`${latestReleaseInfo.tagName}\`)` : ''}以来没有找到新的 commits。`);
            }
        } catch (error) {
            console.error("生成 Release Note 失败:", error);
            if (error.message.includes('403') || error.message.toLowerCase().includes('rate limit')) {
                const token = prompt('检测到 GitHub API 速率限制。请提供您的 GitHub 个人访问令牌(Personal Access Token)以继续:');
                if (token) {
                    saveConfig('githubToken', token);

                    try {
                        return await onGenerateClick(event);
                    } catch (retryError) {
                        alert(`重试失败: ${retryError.message}`);
                    }
                } else {
                    alert('未提供令牌,操作取消。');
                }
            } else {
                alert(`生成 Release Note 失败: ${error.message}\n请检查您的网络连接或GitHub API访问权限。`);
            }
        } finally {
            button.disabled = false;
            buttonLabel.textContent = originalText;
        }
    }

    function initialize() {
        injectStyles();
        const observer = new MutationObserver((mutationsList, observer) => {
            let originalButtonExists = document.getElementById('generate-notes-btn');
            let customButtonExists = document.getElementById('custom-generate-btn');

            if (originalButtonExists && !customButtonExists) {
                createGeneratorButton();
            }

            if (originalButtonExists && document.getElementById('custom-generate-btn')) {
                observer.disconnect();
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
        createGeneratorButton();
    }
    initialize();
})();