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.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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()
  })