GitHub Search Commit Dates

Add commit dates to GitHub search results using GitHub token.

// ==UserScript==
// @name         GitHub Search Commit Dates
// @description  Add commit dates to GitHub search results using GitHub token.
// @icon         https://github.githubassets.com/favicons/favicon-dark.svg
// @version      1.1
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/misc-scripts/
// @supportURL   https://github.com/afkarxyz/misc-scripts/issues
// @license      MIT
// @match        https://github.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        HARDCODED_TOKEN: '',
        SELECTORS: {
            SEARCH_RESULTS: '.Box-sc-g0xbh4-0.dufIPq.notranslate:not([data-processed])',
            FILE_LINK: 'a[data-testid="link-to-search-result"]'
        },
        STYLES: `
            .duplicate-element { 
                border-width: 0px 1px 1px 1px !important;
                border-style: solid !important;
                border-color: var(--borderColor-default, #d0d7de) !important;
                border-radius: 0 !important;
            }
            .duplicate-element * { 
                border-radius: 0 !important;
            }
            .duplicate-element .Box-sc-g0xbh4-0.cJmQqW {
                visibility: hidden !important;
                pointer-events: none !important;
            }
            .duplicate-element .Box-sc-g0xbh4-0.bPbmFy {
                display: inline-flex !important;
                align-items: center !important;
                gap: 4px !important;
                padding: 3px 0 !important;
                min-height: 20px !important;
            }
            .duplicate-element .custom-calendar-icon {
                fill: var(--color-fg-default, #24292f) !important;
                display: block !important;
                flex-shrink: 0 !important;
                position: relative !important;
                top: -1px !important;
            }
            .duplicate-element .search-title {
                font-weight: normal !important;
                margin: 0 !important;
                padding: 0 !important;
                line-height: 1.25 !important;
                color: var(--color-fg-default, #24292f) !important;
            }
            .duplicate-element .Box-sc-g0xbh4-0.bPbmFy > * {
                flex: 0 0 auto !important;
            }
            @media (prefers-color-scheme: dark) {
                .duplicate-element .custom-calendar-icon {
                    fill: var(--color-fg-default, #9198a1) !important;
                }
                .duplicate-element .search-title {
                    color: var(--color-fg-default, #9198a1) !important;
                }
            }
        `
    };

    class GitHubDateAdder {
        constructor() {
            this.token = CONFIG.HARDCODED_TOKEN || GM_getValue('github_token');
            this.setupTokenManager();
            this.setupStyles();
            this.setupObserver();
            this.processSearchResults();
        }

        setupTokenManager() {
            GM_registerMenuCommand('Set GitHub Token', () => {
                const newToken = prompt('Enter your GitHub token:', this.token || '');
                if (newToken) {
                    this.token = newToken;
                    GM_setValue('github_token', newToken);
                }
            });
        }

        setupStyles() {
            if (!document.querySelector('#duplicate-style')) {
                const style = document.createElement('style');
                style.id = 'duplicate-style';
                style.textContent = CONFIG.STYLES;
                document.head.appendChild(style);
            }
        }

        setupObserver() {
            const observer = new MutationObserver(mutations => {
                const hasNewElements = mutations.some(mutation => 
                    mutation.type === 'childList' && 
                    Array.from(mutation.addedNodes).some(node => 
                        node.nodeType === Node.ELEMENT_NODE && 
                        node.querySelector(CONFIG.SELECTORS.SEARCH_RESULTS)
                    )
                );

                if (hasNewElements) {
                    this.processSearchResults();
                }
            });

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

        getTimeAgo(date) {
            const intervals = [
                { label: 'year', seconds: 31536000 },
                { label: 'month', seconds: 2592000 },
                { label: 'week', seconds: 604800 },
                { label: 'day', seconds: 86400 },
                { label: 'hour', seconds: 3600 },
                { label: 'minute', seconds: 60 },
                { label: 'second', seconds: 1 }
            ];

            const seconds = Math.floor((new Date() - date) / 1000);
            const interval = intervals.find(int => Math.floor(seconds / int.seconds) >= 1);

            if (interval) {
                const count = Math.floor(seconds / interval.seconds);
                return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
            }
            return 'just now';
        }

        createDateElement(element, index) {
            if (document.querySelector(`[data-duplicate-id="duplicate-${index}"]`)) return null;

            const newContainer = document.createElement('div');
            newContainer.className = 'Box-sc-g0xbh4-0 dufIPq notranslate duplicate-element';
            newContainer.setAttribute('data-duplicate-id', `duplicate-${index}`);

            const button = element.querySelector('button');
            const searchTitle = element.querySelector('.search-title');
            const parentContainer = searchTitle?.parentElement;
            
            if (button && parentContainer) {
                newContainer.appendChild(button.cloneNode(true));
                newContainer.appendChild(parentContainer.cloneNode(true));
            }

            element.parentNode.insertBefore(newContainer, element.nextSibling);

            const iconElement = newContainer.querySelector('.Box-sc-g0xbh4-0.bPbmFy');
            if (iconElement) {
                iconElement.innerHTML = this.getCalendarIconSVG();
            }

            const titleElement = newContainer.querySelector('.search-title');
            if (titleElement) {
                titleElement.textContent = 'Loading...';
            }

            return newContainer;
        }

        getCalendarIconSVG() {
            return `
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" 
                     style="width: 16px; height: 16px" class="custom-calendar-icon">
                    <path d="M128 0c13.3 0 24 10.7 24 24l0 40 144 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 40 0c35.3 0 64 28.7 64 64l0 16 0 48-16 0-32 0-112 0L48 192l0 256c0 8.8 7.2 16 16 16l220.5 0c12.3 18.8 28 35.1 46.3 48L64 512c-35.3 0-64-28.7-64-64L0 192l0-48 0-16C0 92.7 28.7 64 64 64l40 0 0-40c0-13.3 10.7-24 24-24zM288 368a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-80c-8.8 0-16 7.2-16 16l0 64c0 8.8 7.2 16 16 16l48 0c8.8 0 16-7.2 16-16s-7.2-16-16-16l-32 0 0-48c0-8.8-7.2-16-16-16z"/>
                </svg>
            `;
        }

        async fetchCommitDate(pathInfo) {
            const response = await fetch(
                `https://api.github.com/repos/${pathInfo.owner}/${pathInfo.repo}/commits?path=${pathInfo.path}&per_page=1`,
                {
                    headers: {
                        'Authorization': `token ${this.token}`,
                        'Accept': 'application/vnd.github.v3+json'
                    }
                }
            );
            
            if (response.status === 403) {
                throw new Error('Rate limit exceeded or invalid token');
            }
            
            const data = await response.json();
            return new Date(data[0]?.commit?.author?.date);
        }

        extractPathInfo(element) {
            const fileLink = element.querySelector(CONFIG.SELECTORS.FILE_LINK);
            if (!fileLink) return null;

            const href = fileLink.getAttribute('href');
            const match = href.match(/\/([^/]+)\/([^/]+)\/blob\/[^/]+\/(.+?)(?:\?.*)?(?:#.*)?$/);
            
            return match ? {
                owner: match[1],
                repo: match[2],
                path: match[3]
            } : null;
        }

        updateElementWithDate(element, date) {
            const titleElement = element.querySelector('.search-title');
            if (titleElement && date) {
                const fullDate = `${date.getDate()} ${date.toLocaleString('en-US', { month: 'short' })} ${date.getFullYear()}`;
                titleElement.textContent = `${fullDate} • ${this.getTimeAgo(date)}`;
            }
        }

        async processSearchResults() {
            if (!this.token) {
                console.warn('GitHub token not set. Please set it from the userscript menu or hardcode it in the script.');
                return;
            }

            const searchResults = document.querySelectorAll(CONFIG.SELECTORS.SEARCH_RESULTS);
            
            const elements = Array.from(searchResults).map((element, index) => {
                element.setAttribute('data-processed', '1');
                return this.createDateElement(element, index);
            }).filter(Boolean);

            await Promise.all(elements.map(async (newElement, index) => {
                const originalElement = searchResults[index];
                const pathInfo = this.extractPathInfo(originalElement);
                
                if (!pathInfo) {
                    console.log('Could not extract path info from:', originalElement);
                    return;
                }

                try {
                    const date = await this.fetchCommitDate(pathInfo);
                    if (date) {
                        this.updateElementWithDate(newElement, date);
                    }
                } catch (error) {
                    console.error('Error for path:', pathInfo, error);
                    const titleElement = newElement.querySelector('.search-title');
                    if (titleElement) {
                        titleElement.textContent = 'Error fetching date';
                    }
                }
            }));
        }
    }

    new GitHubDateAdder();
})();