GitLab Add Time Tracking to List Issues

Add a clock button to each issue in the list to log time

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GitLab Add Time Tracking to List Issues
// @namespace    https://greasyfork.org/users/Janjko
// @version      1.0
// @description  Add a clock button to each issue in the list to log time
// @author       Janko
// @match        https://gitlab.com/*/issues*
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    function addTimeButtonsToIssues() {
        // Find all issue items in the list
        const issues = document.querySelectorAll('li[data-testid="issuable-container"]');

        issues.forEach(issue => {
            // Check if button already exists for this issue
            if (issue.querySelector('.add-time-btn-container')) {
                return;
            }

            // Get the issue ID and URL
            const issueLink = issue.querySelector('[data-testid="issuable-title-link"]');
            if (!issueLink) return;

            const issueUrl = issueLink.href;
            const issueId = issue.getAttribute('data-qa-issue-id');

            console.log('Adding time button to issue:', issueId);

            // Find the controls list
            const controlsList = issue.querySelector('.controls');
            if (!controlsList) return;

            // Create the button container with position relative
            const container = document.createElement('li');
            container.className = 'add-time-btn-container';
            container.style.position = 'relative';
            container.style.display = 'inline-block';

            // Create the button
            const btnElement = document.createElement('button');
            btnElement.title = 'Add time spent';
            btnElement.className = 'gl-link gl-reset-color! no-comments';
            btnElement.type = 'button';
            btnElement.style.cursor = 'pointer';
            btnElement.style.padding = '0 8px';
            btnElement.innerHTML = '🕐';

            // Create dropdown menu with fixed positioning relative to button
            const dropdown = document.createElement('div');
            dropdown.className = 'time-dropdown';
            dropdown.style.display = 'none';
            dropdown.style.position = 'fixed'; // Changed from absolute to fixed
            dropdown.style.backgroundColor = '#fff';
            dropdown.style.border = '2px solid #000'; // Make border visible for debugging
            dropdown.style.borderRadius = '4px';
            dropdown.style.boxShadow = '0 4px 8px rgba(0,0,0,0.15)';
            dropdown.style.zIndex = '99999'; // Even higher z-index
            dropdown.style.minWidth = '100px';
            dropdown.style.padding = '4px 0';
            dropdown.style.marginTop = '4px';

            const timeOptions = [
                { label: '10 min', value: '10m' },
                { label: '30 min', value: '30m' },
                { label: '1h', value: '1h' },
                { label: '3h', value: '3h' },
                { label: '5h', value: '5h' },
                { label: '1d', value: '1d' }
            ];

            timeOptions.forEach(option => {
                const item = document.createElement('button');
                item.className = 'time-dropdown-item';
                item.type = 'button';
                item.textContent = option.label;
                item.style.display = 'block';
                item.style.width = '100%';
                item.style.padding = '8px 12px';
                item.style.border = 'none';
                item.style.backgroundColor = 'transparent';
                item.style.cursor = 'pointer';
                item.style.textAlign = 'left';
                item.style.fontSize = '14px';
                item.style.fontFamily = 'inherit';

                item.addEventListener('mouseover', (e) => {
                    item.style.backgroundColor = '#f0f0f0';
                });
                item.addEventListener('mouseout', (e) => {
                    item.style.backgroundColor = 'transparent';
                });

                item.addEventListener('click', (e) => {
                    console.log('Item clicked:', option.value);
                    e.preventDefault();
                    e.stopPropagation();
                    addTimeToIssue(issueUrl, issueId, option.value, btnElement);
                    dropdown.style.display = 'none';
                });

                dropdown.appendChild(item);
            });

            btnElement.addEventListener('click', (e) => {
                console.log('Button clicked, dropdown display was:', dropdown.style.display);
                e.preventDefault();
                e.stopPropagation();

                if (dropdown.style.display === 'none') {
                    // Position the dropdown relative to the button
                    const rect = btnElement.getBoundingClientRect();
                    dropdown.style.top = (rect.bottom + 4) + 'px';
                    dropdown.style.left = rect.left + 'px';
                    dropdown.style.display = 'block';
                } else {
                    dropdown.style.display = 'none';
                }
                console.log('Dropdown display now:', dropdown.style.display);
            });

            container.appendChild(btnElement);
            container.appendChild(dropdown);
            controlsList.appendChild(container);
        });
    }

    // Close dropdowns when clicking outside
    document.addEventListener('click', (e) => {
        if (!e.target.closest('.add-time-btn-container')) {
            document.querySelectorAll('.time-dropdown').forEach(d => {
                d.style.display = 'none';
            });
        }
    }, true);

    function addTimeToIssue(issueUrl, issueId, timeValue, buttonElement) {
        // Change button to show loading state
        const originalHtml = buttonElement.innerHTML;
        buttonElement.innerHTML = '⏳...';
        buttonElement.disabled = true;

        console.log('Adding time to issue:', issueId, 'Value:', timeValue);

        // Get the CSRF token from the page
        const csrfToken = document.querySelector('meta[name="csrf-token"]');
        const token = csrfToken ? csrfToken.getAttribute('content') : '';

        // Build the API URL by replacing /-/issues/ID with /notes
        const apiUrl = issueUrl.replace('/-/issues/', '/notes?target_id=').split('/').slice(0, -1).join('/') + '/notes?target_id=' + issueId + '&target_type=issue';

        // Actually, let's use a simpler approach
        const baseUrl = issueUrl.split('/-/issues/')[0];
        const simpleApiUrl = baseUrl + '/notes?target_id=' + issueId + '&target_type=issue';

        console.log('Calling API URL:', simpleApiUrl);

        const payload = {
            note: {
                noteable_type: 'Issue',
                noteable_id: parseInt(issueId),
                internal: false,
                note: '/spent ' + timeValue
            }
        };

        console.log('Payload:', JSON.stringify(payload));

        fetch(simpleApiUrl, {
            method: 'POST',
            headers: {
                'X-CSRF-Token': token,
                'Accept': 'application/json',
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(payload),
            credentials: 'same-origin'
        })
        .then(response => {
            console.log('Response status:', response.status);
            if (response.ok) {
                buttonElement.innerHTML = '✅';
                setTimeout(() => {
                    buttonElement.innerHTML = originalHtml;
                    buttonElement.disabled = false;
                }, 2000);
            } else {
                return response.text().then(text => {
                    console.error('Response body:', text);
                    throw new Error('Request failed with status ' + response.status);
                });
            }
        })
        .catch(error => {
            console.error('Error adding time:', error);
            buttonElement.innerHTML = '❌';
            setTimeout(() => {
                buttonElement.innerHTML = originalHtml;
                buttonElement.disabled = false;
            }, 2000);
        });
    }

    // Run when page loads
    window.addEventListener('load', addTimeButtonsToIssues);

    // Also try after a short delay
    setTimeout(addTimeButtonsToIssues, 1000);

    // Watch for dynamically loaded issues (pagination, filtering, etc)
    const observer = new MutationObserver(() => {
        clearTimeout(observer.timeout);
        observer.timeout = setTimeout(addTimeButtonsToIssues, 500);
    });

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