GitLab points counter

A userscript that totals Story Point estimates directly on GitLab issue boards, displaying a running SP count next to each column header.

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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()
  })