Add a clock button to each issue in the list to log time
// ==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 });
})();