GitHub Commit Labels

Enhances GitHub commits with beautiful labels for conventional commit types (feat, fix, docs, etc.)

// ==UserScript==
// @name         GitHub Commit Labels
// @namespace    https://github.com/nazdridoy
// @version      1.0.1
// @description  Enhances GitHub commits with beautiful labels for conventional commit types (feat, fix, docs, etc.)
// @author       nazdridoy
// @license      MIT
// @match        https://github.com/*
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-end
// @homepageURL  https://github.com/nazdridoy/github-commit-labels
// @supportURL   https://github.com/nazdridoy/github-commit-labels/issues
// ==/UserScript==

/* 
MIT License

Copyright (c) 2025 nazDridoy

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

(function() {
    'use strict';

    // Color definitions using HSL values
    const COLORS = {
        'green': { bg: 'rgba(35, 134, 54, 0.2)', text: '#7ee787' },
        'purple': { bg: 'rgba(163, 113, 247, 0.2)', text: '#d2a8ff' },
        'blue': { bg: 'rgba(47, 129, 247, 0.2)', text: '#79c0ff' },
        'light-blue': { bg: 'rgba(31, 111, 235, 0.2)', text: '#58a6ff' },
        'yellow': { bg: 'rgba(210, 153, 34, 0.2)', text: '#e3b341' },
        'orange': { bg: 'rgba(219, 109, 40, 0.2)', text: '#ffa657' },
        'gray': { bg: 'rgba(139, 148, 158, 0.2)', text: '#8b949e' },
        'light-green': { bg: 'rgba(57, 211, 83, 0.2)', text: '#56d364' },
        'red': { bg: 'rgba(248, 81, 73, 0.2)', text: '#ff7b72' },
        'dark-yellow': { bg: 'rgba(187, 128, 9, 0.2)', text: '#bb8009' }
    };

    // Define default configuration
    const DEFAULT_CONFIG = {
        removePrefix: true,
        labelStyle: {
            fontSize: '14px',
            fontWeight: '500',
            height: '24px',
            padding: '0 10px',
            marginRight: '8px',
            borderRadius: '20px',
            minWidth: 'auto',
            textAlign: 'center',
            display: 'inline-flex',
            alignItems: 'center',
            justifyContent: 'center',
            whiteSpace: 'nowrap',
            background: 'rgba(0, 0, 0, 0.2)',
            backdropFilter: 'blur(4px)',
            border: '1px solid rgba(240, 246, 252, 0.1)', // Subtle border
            color: '#ffffff'
        },
        commitTypes: {
            // Features
            feat: { emoji: '✨', label: 'Feature', color: 'green' },
            feature: { emoji: '✨', label: 'Feature', color: 'green' },

            // Added
            added: { emoji: '📝', label: 'Added', color: 'green' },
            add: { emoji: '📝', label: 'Added', color: 'green' },

            // Updated
            update: { emoji: '♻️', label: 'Updated', color: 'blue' },
            updated: { emoji: '♻️', label: 'Updated', color: 'blue' },

            // Removed
            removed: { emoji: '🗑️', label: 'Removed', color: 'red' },
            remove: { emoji: '🗑️', label: 'Removed', color: 'red' },

            // Fixes
            fix: { emoji: '🐛', label: 'Fix', color: 'purple' },
            bugfix: { emoji: '🐛', label: 'Fix', color: 'purple' },
            fixed: { emoji: '🐛', label: 'Fix', color: 'purple' },
            hotfix: { emoji: '🚨', label: 'Hot Fix', color: 'red' },

            // Documentation
            docs: { emoji: '📚', label: 'Docs', color: 'blue' },
            doc: { emoji: '📚', label: 'Docs', color: 'blue' },
            documentation: { emoji: '📚', label: 'Docs', color: 'blue' },

            // Styling
            style: { emoji: '💎', label: 'Style', color: 'light-green' },
            ui: { emoji: '🎨', label: 'UI', color: 'light-green' },
            css: { emoji: '💎', label: 'Style', color: 'light-green' },

            // Code Changes
            refactor: { emoji: '📦', label: 'Refactor', color: 'light-blue' },
            perf: { emoji: '🚀', label: 'Performance', color: 'purple' },
            performance: { emoji: '🚀', label: 'Performance', color: 'purple' },
            optimize: { emoji: '⚡', label: 'Optimize', color: 'purple' },

            // Testing
            test: { emoji: '🧪', label: 'Test', color: 'yellow' },
            tests: { emoji: '🧪', label: 'Test', color: 'yellow' },
            testing: { emoji: '🧪', label: 'Test', color: 'yellow' },

            // Build & Deploy
            build: { emoji: '🛠', label: 'Build', color: 'orange' },
            ci: { emoji: '⚙️', label: 'CI', color: 'gray' },
            cd: { emoji: '🚀', label: 'CD', color: 'gray' },
            deploy: { emoji: '📦', label: 'Deploy', color: 'orange' },
            release: { emoji: '🚀', label: 'Deploy', color: 'orange' },

            // Maintenance
            chore: { emoji: '♻️', label: 'Chore', color: 'light-green' },
            deps: { emoji: '📦', label: 'Dependencies', color: 'light-green' },
            dep: { emoji: '📦', label: 'Dependencies', color: 'light-green' },
            dependencies: { emoji: '📦', label: 'Dependencies', color: 'light-green' },
            revert: { emoji: '🗑', label: 'Revert', color: 'red' },
            wip: { emoji: '🚧', label: 'WIP', color: 'dark-yellow' }
        }
    };

    // Get saved configuration or use default
    const USER_CONFIG = GM_getValue('commitLabelsConfig', DEFAULT_CONFIG);

    // Create configuration window
    function createConfigWindow() {
        const configWindow = document.createElement('div');
        configWindow.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: #0d1117;
            border: 1px solid #30363d;
            border-radius: 6px;
            padding: 20px;
            z-index: 9999;
            width: 600px;
            max-height: 80vh;
            overflow-y: auto;
            color: #c9d1d9;
            box-shadow: 0 0 10px rgba(0,0,0,0.5);
        `;

        const title = document.createElement('h2');
        title.textContent = 'Commit Labels Configuration';
        title.style.marginBottom = '20px';
        configWindow.appendChild(title);

        // Remove Prefix Option
        const prefixDiv = document.createElement('div');
        prefixDiv.style.marginBottom = '20px';
        const prefixCheckbox = document.createElement('input');
        prefixCheckbox.type = 'checkbox';
        prefixCheckbox.checked = USER_CONFIG.removePrefix;
        prefixCheckbox.id = 'remove-prefix';
        const prefixLabel = document.createElement('label');
        prefixLabel.htmlFor = 'remove-prefix';
        prefixLabel.textContent = ' Remove commit type prefix from message';
        prefixDiv.appendChild(prefixCheckbox);
        prefixDiv.appendChild(prefixLabel);
        configWindow.appendChild(prefixDiv);

        // Commit Types Configuration
        const typesContainer = document.createElement('div');
        typesContainer.style.marginBottom = '20px';

        // Group commit types by their label
        const groupedTypes = {};
        Object.entries(USER_CONFIG.commitTypes).forEach(([type, config]) => {
            const key = config.label;
            if (!groupedTypes[key]) {
                groupedTypes[key] = {
                    types: [],
                    config: config
                };
            }
            groupedTypes[key].types.push(type);
        });

        // Create rows for grouped types
        Object.entries(groupedTypes).forEach(([label, { types, config }]) => {
            const typeDiv = document.createElement('div');
            typeDiv.style.marginBottom = '10px';
            typeDiv.style.display = 'flex';
            typeDiv.style.alignItems = 'center';
            typeDiv.style.gap = '10px';

            // Type names (with aliases) and edit button container
            const typeContainer = document.createElement('div');
            typeContainer.style.display = 'flex';
            typeContainer.style.width = '150px';
            typeContainer.style.alignItems = 'center';
            typeContainer.style.gap = '4px';

            const typeSpan = document.createElement('span');
            typeSpan.style.color = '#8b949e';
            typeSpan.style.flex = '1';
            typeSpan.textContent = types.join(', ') + ':';

            const editAliasButton = document.createElement('button');
            editAliasButton.textContent = '✏️';
            editAliasButton.title = 'Edit Aliases';
            editAliasButton.style.cssText = `
                padding: 2px 4px;
                background: #21262d;
                color: #58a6ff;
                border: 1px solid #30363d;
                border-radius: 4px;
                cursor: pointer;
                font-size: 10px;
            `;

            editAliasButton.onclick = () => {
                const currentAliases = types.join(', ');
                const newAliases = prompt('Edit aliases (separate with commas):', currentAliases);

                if (newAliases && newAliases.trim()) {
                    const newTypes = newAliases.split(',').map(t => t.trim().toLowerCase()).filter(t => t);

                    // Check if any new aliases conflict with other types
                    const conflictingType = newTypes.find(type =>
                        USER_CONFIG.commitTypes[type] && !types.includes(type)
                    );

                    if (conflictingType) {
                        alert(`The alias "${conflictingType}" already exists in another group!`);
                        return;
                    }

                    // Remove old types
                    types.forEach(type => delete USER_CONFIG.commitTypes[type]);

                    // Add new types with same config
                    newTypes.forEach(type => {
                        USER_CONFIG.commitTypes[type] = { ...config };
                    });

                    // Update the display
                    typeSpan.textContent = newTypes.join(', ') + ':';

                    // Update dataset for inputs
                    const inputs = typeDiv.querySelectorAll('input, select');
                    inputs.forEach(input => {
                        input.dataset.types = newTypes.join(',');
                    });
                }
            };

            typeContainer.appendChild(typeSpan);
            typeContainer.appendChild(editAliasButton);
            typeDiv.appendChild(typeContainer);

            // Emoji input
            const emojiInput = document.createElement('input');
            emojiInput.type = 'text';
            emojiInput.value = config.emoji;
            emojiInput.style.width = '40px';
            emojiInput.dataset.types = types.join(',');
            emojiInput.dataset.field = 'emoji';
            typeDiv.appendChild(emojiInput);

            // Label input
            const labelInput = document.createElement('input');
            labelInput.type = 'text';
            labelInput.value = config.label;
            labelInput.style.width = '120px';
            labelInput.dataset.types = types.join(',');
            labelInput.dataset.field = 'label';
            typeDiv.appendChild(labelInput);

            // Color select
            const colorSelect = document.createElement('select');
            Object.keys(COLORS).forEach(color => {
                const option = document.createElement('option');
                option.value = color;
                option.textContent = color;
                if (config.color === color) option.selected = true;
                colorSelect.appendChild(option);
            });
            colorSelect.dataset.types = types.join(',');
            colorSelect.dataset.field = 'color';
            typeDiv.appendChild(colorSelect);

            // Delete button
            const deleteButton = document.createElement('button');
            deleteButton.textContent = '🗑️';
            deleteButton.style.cssText = `
                padding: 2px 8px;
                background: #21262d;
                color: #f85149;
                border: 1px solid #30363d;
                border-radius: 4px;
                cursor: pointer;
            `;
            deleteButton.onclick = () => {
                if (confirm(`Delete commit types "${types.join(', ')}"?`)) {
                    typeDiv.remove();
                    types.forEach(type => delete USER_CONFIG.commitTypes[type]);
                }
            };
            typeDiv.appendChild(deleteButton);

            typesContainer.appendChild(typeDiv);
        });

        // Add "Add New Type" button
        const addNewButton = document.createElement('button');
        addNewButton.textContent = '+ Add New Type';
        addNewButton.style.cssText = `
            margin-bottom: 15px;
            padding: 5px 16px;
            background: #238636;
            color: #fff;
            border: none;
            border-radius: 6px;
            cursor: pointer;
        `;

        addNewButton.onclick = () => {
            const typeInput = prompt('Enter the commit type and aliases (separated by commas, e.g., "added, add"):', '');
            if (typeInput && typeInput.trim()) {
                const types = typeInput.split(',').map(t => t.trim().toLowerCase()).filter(t => t);

                // Check if any of the types already exist
                const existingType = types.find(type => USER_CONFIG.commitTypes[type]);
                if (existingType) {
                    alert(`The commit type "${existingType}" already exists!`);
                    return;
                }

                // Create base config for all aliases
                const baseConfig = {
                    emoji: '🔄',
                    label: types[0].charAt(0).toUpperCase() + types[0].slice(1),
                    color: 'blue'
                };

                // Add all types to config with the same settings
                types.forEach(type => {
                    USER_CONFIG.commitTypes[type] = { ...baseConfig };
                });

                // Create and add new type row
                const typeDiv = document.createElement('div');
                typeDiv.style.marginBottom = '10px';
                typeDiv.style.display = 'flex';
                typeDiv.style.alignItems = 'center';
                typeDiv.style.gap = '10px';

                // Type names (with aliases)
                const typeSpan = document.createElement('span');
                typeSpan.style.width = '150px';
                typeSpan.style.color = '#8b949e';
                typeSpan.textContent = types.join(', ') + ':';
                typeDiv.appendChild(typeSpan);

                // Emoji input
                const emojiInput = document.createElement('input');
                emojiInput.type = 'text';
                emojiInput.value = baseConfig.emoji;
                emojiInput.style.width = '40px';
                emojiInput.dataset.types = types.join(',');
                emojiInput.dataset.field = 'emoji';
                typeDiv.appendChild(emojiInput);

                // Label input
                const labelInput = document.createElement('input');
                labelInput.type = 'text';
                labelInput.value = baseConfig.label;
                labelInput.style.width = '120px';
                labelInput.dataset.types = types.join(',');
                labelInput.dataset.field = 'label';
                typeDiv.appendChild(labelInput);

                // Color select
                const colorSelect = document.createElement('select');
                Object.keys(COLORS).forEach(color => {
                    const option = document.createElement('option');
                    option.value = color;
                    option.textContent = color;
                    if (color === 'blue') option.selected = true;
                    colorSelect.appendChild(option);
                });
                colorSelect.dataset.types = types.join(',');
                colorSelect.dataset.field = 'color';
                typeDiv.appendChild(colorSelect);

                // Delete button
                const deleteButton = document.createElement('button');
                deleteButton.textContent = '🗑️';
                deleteButton.style.cssText = `
                    padding: 2px 8px;
                    background: #21262d;
                    color: #f85149;
                    border: 1px solid #30363d;
                    border-radius: 4px;
                    cursor: pointer;
                `;
                deleteButton.onclick = () => {
                    if (confirm(`Delete commit types "${types.join(', ')}"?`)) {
                        typeDiv.remove();
                        types.forEach(type => delete USER_CONFIG.commitTypes[type]);
                    }
                };
                typeDiv.appendChild(deleteButton);

                typesContainer.appendChild(typeDiv);
            }
        };

        configWindow.appendChild(addNewButton);
        configWindow.appendChild(typesContainer);

        // Save and Close buttons
        const buttonContainer = document.createElement('div');
        buttonContainer.style.display = 'flex';
        buttonContainer.style.gap = '10px';
        buttonContainer.style.justifyContent = 'flex-end';

        const saveButton = document.createElement('button');
        saveButton.textContent = 'Save';
        saveButton.style.cssText = `
            padding: 5px 16px;
            background: #238636;
            color: #fff;
            border: none;
            border-radius: 6px;
            cursor: pointer;
        `;

        const closeButton = document.createElement('button');
        closeButton.textContent = 'Close';
        closeButton.style.cssText = `
            padding: 5px 16px;
            background: #21262d;
            color: #c9d1d9;
            border: 1px solid #30363d;
            border-radius: 6px;
            cursor: pointer;
        `;

        // Add Reset button next to Save and Close
        const resetButton = document.createElement('button');
        resetButton.textContent = 'Reset to Default';
        resetButton.style.cssText = `
            padding: 5px 16px;
            background: #21262d;
            color: #f85149;
            border: 1px solid #30363d;
            border-radius: 6px;
            cursor: pointer;
            margin-right: auto;  // This pushes Save/Close to the right
        `;

        resetButton.onclick = () => {
            if (confirm('Are you sure you want to reset all settings to default? This will remove all custom types and settings.')) {
                GM_setValue('commitLabelsConfig', DEFAULT_CONFIG);
                location.reload();
            }
        };

        saveButton.onclick = () => {
            const newConfig = { ...USER_CONFIG };
            newConfig.removePrefix = prefixCheckbox.checked;
            newConfig.commitTypes = {};

            typesContainer.querySelectorAll('input, select').forEach(input => {
                const types = input.dataset.types.split(',');
                const field = input.dataset.field;

                types.forEach(type => {
                    if (!newConfig.commitTypes[type]) {
                        newConfig.commitTypes[type] = {};
                    }
                    newConfig.commitTypes[type][field] = input.value;
                });
            });

            GM_setValue('commitLabelsConfig', newConfig);
            location.reload();
        };

        closeButton.onclick = () => {
            document.body.removeChild(configWindow);
        };

        buttonContainer.appendChild(resetButton);
        buttonContainer.appendChild(closeButton);
        buttonContainer.appendChild(saveButton);
        configWindow.appendChild(buttonContainer);

        document.body.appendChild(configWindow);
    }

    // Register configuration menu command
    GM_registerMenuCommand('Configure Commit Labels', createConfigWindow);

    // Check if we're on a commit page
    function isCommitPage() {
        return window.location.pathname.includes('/commits') ||
               window.location.pathname.includes('/commit/');
    }

    function addCommitLabels() {
        // Only proceed if we're on a commit page
        if (!isCommitPage()) return;

        // Update selector to match GitHub's current DOM structure
        const commitMessages = document.querySelectorAll('.markdown-title a[data-pjax="true"]');

        commitMessages.forEach(message => {
            const text = message.textContent.trim();
            const match = text.match(/^(\w+)(?:\([\w-]+\))?:\s*(.*)/);

            if (match) {
                const type = match[1].toLowerCase();
                const restOfMessage = match[2];

                if (USER_CONFIG.commitTypes[type]) {
                    // Only add label if it hasn't been added yet
                    if (!message.parentElement.querySelector('.commit-label')) {
                        const label = document.createElement('span');
                        label.className = 'commit-label';
                        const color = COLORS[USER_CONFIG.commitTypes[type].color];

                        // Calculate perceived lightness (using GitHub's formula)
                        const perceivedL = (color.l / 100);
                        const lightnessSwitch = perceivedL <= 0.6 ? 1 : 0;
                        const lightenBy = ((0.6 - perceivedL) * 100) * lightnessSwitch;

                        // Apply styles
                        const styles = {
                            ...USER_CONFIG.labelStyle,
                            '--label-h': color.h,
                            '--label-s': color.s,
                            '--label-l': color.l,
                            'color': `hsl(${color.h}, ${color.s}%, ${color.l + lightenBy}%)`,
                            'background': `hsla(${color.h}, ${color.s}%, ${color.l}%, 0.18)`,
                            'borderColor': `hsla(${color.h}, ${color.s}%, ${color.l + lightenBy}%, 0.3)`,
                            backgroundColor: color.bg,
                            color: color.text
                        };

                        label.style.cssText = Object.entries(styles)
                            .map(([key, value]) => `${key.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}: ${value}`)
                            .join(';');

                        // Add hover effect
                        label.addEventListener('mouseenter', () => {
                            label.style.transform = 'translateY(-1px)';
                            label.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)';
                        });

                        label.addEventListener('mouseleave', () => {
                            label.style.transform = 'translateY(0)';
                            label.style.boxShadow = styles.boxShadow;
                        });

                        const emoji = document.createElement('span');
                        emoji.style.marginRight = '4px';
                        emoji.style.fontSize = '14px';
                        emoji.style.lineHeight = '1';
                        emoji.textContent = USER_CONFIG.commitTypes[type].emoji;

                        const labelText = document.createElement('span');
                        labelText.textContent = USER_CONFIG.commitTypes[type].label;

                        label.appendChild(emoji);
                        label.appendChild(labelText);
                        message.parentElement.insertBefore(label, message);

                        // Update the commit message text to remove the type prefix if enabled
                        if (USER_CONFIG.removePrefix) {
                            message.textContent = restOfMessage;
                        }
                    }
                }
            }
        });
    }

    // Only set up observers if we're on a commit page
    function initialize() {
        // Initial run
        addCommitLabels();

        // Watch for DOM changes
        const observer = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                if (mutation.addedNodes.length) {
                    addCommitLabels();
                }
            }
        });

        // Start observing the document with the configured parameters
        observer.observe(document.body, { childList: true, subtree: true });
    }

    // Initialize on page load
    initialize();

    // Handle GitHub's client-side navigation
    const navigationObserver = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            if (mutation.type === 'childList') {
                // Check if we're on a commit page after navigation
                if (isCommitPage()) {
                    // Small delay to ensure GitHub has finished rendering
                    setTimeout(addCommitLabels, 100);
                }
            }
        }
    });

    // Observe changes to the main content area
    navigationObserver.observe(document.body, {
        childList: true,
        subtree: true
    });

    // Listen for popstate events (browser back/forward navigation)
    window.addEventListener('popstate', () => {
        if (isCommitPage()) {
            setTimeout(addCommitLabels, 100);
        }
    });

    // Listen for GitHub's custom navigation event
    document.addEventListener('turbo:render', () => {
        if (isCommitPage()) {
            setTimeout(addCommitLabels, 100);
        }
    });
})();