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.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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