Greasy Fork is available in English.
A userscript that totals Story Point estimates directly on GitLab issue boards, displaying a running SP count next to each column header.
// ==UserScript== // @name GitLab points counter // @description A userscript that totals Story Point estimates directly on GitLab issue boards, displaying a running SP count next to each column header. // @version 2.1 // @namespace https://bbauer.eu // @license GPL-3.0-or-later // @grant none // @match https://gitlab.com/*/-/boards* // @include https://gitlab.*/*/-/boards* // @author Benedikt Bauer <[email protected]> // @compatible tampermonkey // @compatible greasemonkey // @compatible violentmonkey // ==/UserScript== var getOpname = /(query|mutation) ?([\w\d-_]+)? ?\(.*?\)? \{/ function gql (str) { str = Array.isArray(str) ? str.join('') : str var name = getOpname.exec(str) return function (variables) { var data = { query: str } if (variables) data.variables = variables if (name && name.length) { var operationName = name[2] if (operationName) data.operationName = name[2] } return JSON.stringify(data) } } async function runCounter() { // Remove any badges injected by a previous run before re-injecting document.querySelectorAll('.sp-points-badge').forEach(el => el.remove()) console.log('[pointscounter] Script starting on', window.location.href) // Derive host, project path and board ID from the current URL // URL pattern: https://<host>/<namespace>/<project>/-/boards/<boardId> const urlMatch = window.location.pathname.match(/^\/(.+?)\/-\/boards\/(\d+)/) if (!urlMatch) { console.error('[pointscounter] Could not parse project path and board ID from URL:', window.location.pathname) return } const projectPath = urlMatch[1] const boardID = urlMatch[2] const apiBase = window.location.origin console.log(`[pointscounter] Detected project="${projectPath}", boardID="${boardID}", apiBase="${apiBase}"`) var queryLists = gql`query ($fullPath: ID!, $boardID: BoardID!){ project(fullPath: $fullPath) { board(id: $boardID) { lists { nodes { id title issues { nodes { title } } } } } } } ` try { async function gqlfetch(query, variables) { console.log('[pointscounter] Sending GraphQL request to', `${apiBase}/api/graphql`, 'with variables', variables) const res = await fetch(`${apiBase}/api/graphql`, { body: query(variables), method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', }) console.log('[pointscounter] GraphQL response status:', res.status, res.statusText) const json = await res.json() if (json.errors) { console.error('[pointscounter] GraphQL errors:', json.errors) } return Promise.resolve(json) } const listsJSON = await gqlfetch(queryLists, {fullPath: projectPath, boardID: `gid://gitlab/Board/${boardID}`}) console.log('[pointscounter] Raw GraphQL response:', listsJSON) const lists = listsJSON.data.project.board.lists.nodes.map(item => ({ id: item.id.match(/\/(\d+)$/)[1], title: item.title, issues: item.issues.nodes, })) console.log(`[pointscounter] Found ${lists.length} list(s):`, lists.map(l => `"${l.title}" (${l.issues.length} issues)`)) const listMap = lists.map(({id, title, issues}) => { let totalPoints = issues.reduce((accumulator, issue) => { const matches = issue.title.match(/\( *(\d+) *SP *\)/) if (matches !== null) { const sp = Number(matches[1]) return accumulator + (Number.isNaN(sp) ? 0 : sp) } return accumulator; }, 0) console.log(`[pointscounter] List "${title}": ${totalPoints} SP across ${issues.length} issue(s)`) const spAnalytics = document.createElement('div') spAnalytics.className = 'sp-points-badge issue-count-badge gl-inline-flex gl-pr-0 no-drag text-secondary' spAnalytics.innerHTML = `<span class="issue-count-badge-count gl-inline-flex gl-pr-2 no-drag gl-text-gray-500"><svg data-testid="issues-icon" aria-hidden="true" class="gl-mr-2 gl-icon s16"><symbol viewBox="0 0 16 16" id="chart" xmlns="http://www.w3.org/2000/svg"><path d="M2 1a1 1 0 0 1 .993.883L3 2v11h11a1 1 0 0 1 .117 1.993L14 15H3a2 2 0 0 1-1.995-1.85L1 13V2a1 1 0 0 1 1-1zm4 6a1 1 0 0 1 .993.883L7 8v2a1 1 0 0 1-1.993.117L5 10V8a1 1 0 0 1 1-1zm4-4a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V4a1 1 0 0 1 1-1zm4 2a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V6a1 1 0 0 1 1-1z"></path></symbol><use href="#chart"></use></svg><div class="issue-count text-nowrap"><span class="js-issue-size">${totalPoints} SP</span></div></span>` // Find the column header h2 by matching the list title text, then insert the badge after it. // This avoids relying on fragile nth-child selectors that break across GitLab versions. const listElement = document.querySelector(`[data-list-id="gid://gitlab/List/${id}"]`) if (!listElement) { console.warn(`[pointscounter] Could not find list element for list "${title}" (id=${id}) — the board may not have finished rendering`) return {id, title, totalPoints} } if (listElement.classList.contains('is-collapsed')) { console.log(`[pointscounter] List "${title}" is collapsed, rotating badge`) spAnalytics.setAttribute('style', 'transform: rotate(90deg); transform-origin: 50% 50%;') } const header = Array.from(listElement.querySelectorAll('h2')).find( el => el.textContent.trim().includes(title) ) || listElement.querySelector('h2') if (header) { header.after(spAnalytics) console.log(`[pointscounter] Badge injected after header of list "${title}"`) } else { console.warn(`[pointscounter] Could not find h2 header for list "${title}" — badge not injected`) } return {id, title, totalPoints}; }) console.log('[pointscounter] Done. Summary:', listMap) return Promise.resolve(listMap) } catch (err) { console.error('[pointscounter] Unexpected error:', err) } } function onBoardUrl() { if (!window.location.pathname.match(/\/-\/boards\/\d+/)) return console.log('[pointscounter] Board URL detected, scheduling counter run') // Small delay to allow GitLab's SPA to finish rendering the board columns setTimeout(() => { runCounter().then(data => { if (data) console.log('[pointscounter] Finished successfully with', data.length, 'list(s)') }) }, 1000) } // Userscript sandboxes run in an isolated JS context, so patching history directly // doesn't intercept the page's own pushState calls. Instead, watch for any DOM // mutation and re-run whenever the URL changes to a board URL. let lastUrl = location.href new MutationObserver(() => { const url = location.href if (url !== lastUrl) { lastUrl = url onBoardUrl() } }).observe(document, { subtree: true, childList: true }) window.addEventListener('load', function() { onBoardUrl() })