├── assets └── github-projects-story-points.png ├── LICENSE ├── README.md └── script.user.js /assets/github-projects-story-points.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pkosiec/github-projects-story-points/HEAD/assets/github-projects-story-points.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Paweł Kosiec 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Projects Story Points 2 | 3 | > **:warning: DEPRECATED:** This project is no longer needed as GitHub issues supports custom fields now. Use [number fields](https://docs.github.com/en/issues/planning-and-tracking-with-projects/understanding-fields/about-text-and-number-fields#adding-a-number-field) to add estimations to your issues and display [sum of the field](https://docs.github.com/en/issues/planning-and-tracking-with-projects/customizing-views-in-your-project/customizing-the-board-layout#showing-the-sum-of-a-number-field) on your board. 4 | 5 | Use Story Points in GitHub Project board without a hassle. No labels or issue title modifications needed. 6 | 7 |  8 | 9 | ### Motivation 10 | 11 | There are plenty of similar tools. However, all existing plugins or scripts I found base on Github labels or tags in task titles, which doesn't look professional. I prepared the script to keep the estimations internal, visible only on GitHub Project boards. The boards can set as private, which means this script allows to show Story Points only for authors or organization members. 12 | 13 | ### Features 14 | 15 | The script has the following features: 16 | 17 | - Show total Story Points number per column 18 | - Show number of estimated cards and total cards number per column 19 | - Highlight not estimated cards 20 | - Highlight cards with invalid estimation 21 | - Show total Story Points on the project board 22 | - Ignore specific columns 23 | 24 | Current implementation of the script recalculates Story Points every 2 seconds. 25 | 26 | ### Installation 27 | 28 | 1. Install [Violentmonkey](https://violentmonkey.github.io/) (open source) or [Tampermonkey](http://www.tampermonkey.net/) (closed source) plugin for your favorite web browser. 29 | 2. Navigate to the [GitHub Project Story Points User Script](https://raw.githubusercontent.com/pkosiec/github-projects-story-points/master/script.user.js) location. The script format is detected automatically and Tampermonkey will ask to install it. 30 | 3. The userscript manager will watch the script location and it will update the script automatically once new version is released. 31 | 32 | ### Usage 33 | 34 | 1. Navigate to your GitHub Project board. 35 | 1. Add a note to a column with a task description. 36 | > **NOTE**: To reference actual issue, paste a link into the note. 37 | 1. To define your Story Points value, include the following codeblock: 38 | ```` 39 | ```est 40 | SP: {value} 41 | ``` 42 | ```` 43 | For example, for Story Points value of 3, the actual codeblock is: 44 | ```` 45 | ```est 46 | SP: 3 47 | ``` 48 | ```` 49 | 1. Observe Story Point Column Summary update. 50 | 51 | ### Configuration 52 | 53 | Currently the plugin doesn't expose official configuration options. As a workaround, you can modify the following lines of the script: 54 | 55 | ```javascript 56 | const refreshInterval = 2000; 57 | const highlightNotEstimatedCards = true; 58 | const showTotalBoardStoryPoints = true; 59 | 60 | // the column cards will be excluded from validation and counting Story Points: 61 | // both from column and board Story Points count. 62 | const excludedColumns = ["Inbox"]; 63 | 64 | // the column cards will be validated as usual and the column summary will be visible, 65 | // but the Story Points from this column won't be counted towards the board total Story Points. 66 | const excludedColumnsFromBoardStoryPointsCount = ["Backlog"]; 67 | ``` 68 | 69 | However, keep in mind that every script update will overwrite your configuration values. 70 | 71 | ### Example 72 | 73 | To see a live example, install the script and navigate to the [sample GitHub Project](https://github.com/pkosiec/gh-projects-story-points/projects/1). 74 | -------------------------------------------------------------------------------- /script.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name GitHub Projects Story Points 3 | // @namespace pkosiec 4 | // @version 0.2.1 5 | // @description Use Story Points in GitHub Project board without a hassle. No labels or issue title modifications needed. 6 | // @author Pawel Kosiec 7 | // @website https://github.com/pkosiec/gh-projects-story-points/ 8 | // @match https://github.com/*/projects/* 9 | // @grant none 10 | // @license MIT 11 | // @run-at document-idle 12 | // ==/UserScript== 13 | 14 | (function () { 15 | "use strict"; 16 | 17 | /** 18 | * Configuration 19 | */ 20 | 21 | const refreshInterval = 2000; 22 | const highlightNotEstimatedCards = true; 23 | const showTotalBoardStoryPoints = true; 24 | 25 | // the column cards will be excluded from validation and counting Story Points: 26 | // both from column and board Story Points count. 27 | const excludedColumns = ["Inbox"]; 28 | 29 | // the column cards will be validated as usual and the column summary will be visible, 30 | // but the Story Points from this column won't be counted towards the board total Story Points. 31 | const excludedColumnsFromBoardStoryPointsCount = ["Backlog"]; 32 | 33 | /** 34 | * Internal values 35 | */ 36 | 37 | const storyPointsColumnSummaryClass = "ghp-sp-column-summary"; 38 | const storyPointsBoardSummaryClass = "ghp-sp-board-summary"; 39 | const notEstimatedCardClass = "ghp-sp-not-estimated"; 40 | const invalidEstimationCardClass = "ghp-sp-estimation-invalid"; 41 | 42 | const estimationBlockSelector = `pre[lang="est"]`; 43 | const columnSelector = ".project-column"; 44 | const columnHeaderSelector = ".details-container"; 45 | const columnHeaderNameSelector = `${columnHeaderSelector} .js-project-column-name`; 46 | const cardSelector = "article.issue-card"; 47 | const projectBoardSelector = ".project-columns-container"; 48 | const boardHeaderSelector = ".project-header .project-header-controls"; 49 | const customCSS = ` 50 | ${cardSelector}.${notEstimatedCardClass} { 51 | background: #fff7bb!important; 52 | } 53 | 54 | ${cardSelector}.${invalidEstimationCardClass} { 55 | background: #fbc8c8!important; 56 | } 57 | 58 | .${storyPointsColumnSummaryClass} { 59 | padding: 0 8px 8px; 60 | } 61 | 62 | .${storyPointsColumnSummaryClass} p, .${storyPointsBoardSummaryClass} p { 63 | margin: 0; 64 | } 65 | `; 66 | 67 | if (document.querySelector(projectBoardSelector) === null) { 68 | return; 69 | } 70 | 71 | console.log("Running GitHub Projects Story Points..."); 72 | includeCustomCSS(); 73 | runPeriodically(refreshInterval); 74 | 75 | function runPeriodically(refreshInterval) { 76 | setInterval(() => { 77 | run(); 78 | }, refreshInterval); 79 | } 80 | 81 | function run() { 82 | removeExistingSummaries(); 83 | 84 | const columns = getColumns(); 85 | 86 | let totalBoardStoryPoints = 0; 87 | columns.forEach((column) => { 88 | if (excludedColumns.includes(column.name)) { 89 | addExcludedLabelForColumnIfShould(column.node); 90 | return; 91 | } 92 | 93 | const cardNodes = getCardNodes(column.node); 94 | 95 | const totalCardNodesCount = cardNodes.length; 96 | let estimatedCardsCount = 0; 97 | let totalColumnStoryPoints = 0; 98 | cardNodes.forEach((cardNode) => { 99 | try { 100 | const cardStoryPoints = getCardStoryPoints(cardNode); 101 | totalColumnStoryPoints += cardStoryPoints; 102 | estimatedCardsCount++; 103 | } catch (err) { 104 | highlightCard(cardNode, err); 105 | } 106 | }); 107 | 108 | if (!excludedColumnsFromBoardStoryPointsCount.includes(column.name)) { 109 | totalBoardStoryPoints += totalColumnStoryPoints; 110 | } 111 | 112 | addStoryPointsColumnSummary(column.node, { 113 | totalColumnStoryPoints, 114 | estimatedCardsCount, 115 | totalCardNodesCount, 116 | }); 117 | }); 118 | 119 | if (showTotalBoardStoryPoints) { 120 | const boardHeaderNode = getBoardHeaderNode(); 121 | addStoryPointsBoardSummary(boardHeaderNode, totalBoardStoryPoints); 122 | } 123 | } 124 | 125 | function removeExistingSummaries() { 126 | document 127 | .querySelectorAll(`.${storyPointsColumnSummaryClass}`) 128 | .forEach((elem) => elem.remove()); 129 | 130 | const boardSummaryNode = document.querySelector( 131 | `${boardHeaderSelector} .${storyPointsBoardSummaryClass}` 132 | ); 133 | if (boardSummaryNode !== null) { 134 | boardSummaryNode.remove(); 135 | } 136 | } 137 | 138 | function getBoardHeaderNode() { 139 | return document.querySelector(boardHeaderSelector); 140 | } 141 | 142 | function getColumns() { 143 | const columnNodes = document.querySelectorAll(columnSelector); 144 | const columnNodesArray = [...columnNodes]; 145 | 146 | return columnNodesArray.map((columnNode) => { 147 | const headerNode = columnNode.querySelector(columnHeaderNameSelector); 148 | if (headerNode === null) { 149 | return { 150 | node: columnNode, 151 | }; 152 | } 153 | 154 | return { 155 | node: columnNode, 156 | name: headerNode.innerText, 157 | }; 158 | }); 159 | } 160 | 161 | function getCardNodes(columnNode) { 162 | return columnNode.querySelectorAll(cardSelector); 163 | } 164 | 165 | function highlightCard(node, err) { 166 | switch (true) { 167 | case err instanceof NoEstimationCardError: 168 | if (highlightNotEstimatedCards) { 169 | node.classList.add(notEstimatedCardClass); 170 | } 171 | return; 172 | case err instanceof InvalidEstimationCardError: 173 | node.classList.add(invalidEstimationCardClass); 174 | return; 175 | } 176 | } 177 | 178 | class NoEstimationCardError extends Error {} 179 | class InvalidEstimationCardError extends Error {} 180 | 181 | function getCardStoryPoints(node) { 182 | const estimationCodeBlockNodes = node.querySelectorAll( 183 | estimationBlockSelector 184 | ); 185 | 186 | if (estimationCodeBlockNodes.length === 0) { 187 | throw new NoEstimationCardError(); 188 | } 189 | 190 | if (estimationCodeBlockNodes.length > 1) { 191 | throw new InvalidEstimationCardError(); 192 | } 193 | 194 | const storyPoints = getStoryPoints(estimationCodeBlockNodes[0]); 195 | if (storyPoints < 0) { 196 | throw new InvalidEstimationCardError(); 197 | } 198 | 199 | return storyPoints; 200 | } 201 | 202 | function getStoryPoints(estimationCodeBlockNode) { 203 | const estimationText = estimationCodeBlockNode.innerText.replace("SP:", ""); 204 | const estNumber = Number(estimationText); 205 | 206 | if (isNaN(estNumber) || estNumber < 0) { 207 | return -1; 208 | } 209 | 210 | return estNumber; 211 | } 212 | 213 | function includeCustomCSS() { 214 | const styleNode = document.createElement("style"); 215 | styleNode.type = "text/css"; 216 | styleNode.appendChild(document.createTextNode(customCSS)); 217 | 218 | document.head.appendChild(styleNode); 219 | } 220 | 221 | function addStoryPointsColumnSummary( 222 | columnNode, 223 | { totalColumnStoryPoints, estimatedCardsCount, totalCardNodesCount } 224 | ) { 225 | const projectColumnHeader = columnNode.querySelector(columnHeaderSelector); 226 | 227 | const summaryDiv = document.createElement("div"); 228 | summaryDiv.className = storyPointsColumnSummaryClass; 229 | summaryDiv.innerHTML = `
230 | Story Points: ${totalColumnStoryPoints} (Estimated: ${estimatedCardsCount}/${totalCardNodesCount}) 231 |
`; 232 | projectColumnHeader.appendChild(summaryDiv); 233 | } 234 | 235 | function addExcludedLabelForColumnIfShould(columnNode) { 236 | const projectColumnHeader = columnNode.querySelector(columnHeaderSelector); 237 | 238 | if ( 239 | projectColumnHeader.querySelector(`.${storyPointsColumnSummaryClass}`) !== 240 | null 241 | ) { 242 | return; 243 | } 244 | 245 | const excludedColumnDiv = document.createElement("div"); 246 | excludedColumnDiv.className = storyPointsColumnSummaryClass; 247 | excludedColumnDiv.innerHTML = `248 | Story Points count disabled 249 |
`; 250 | projectColumnHeader.appendChild(excludedColumnDiv); 251 | } 252 | 253 | function addStoryPointsBoardSummary(boardHeaderNode, totalBoardStoryPoints) { 254 | const summaryDiv = document.createElement("div"); 255 | summaryDiv.className = storyPointsBoardSummaryClass; 256 | 257 | let additionalContent = ""; 258 | if ( 259 | excludedColumns.length > 0 || 260 | excludedColumnsFromBoardStoryPointsCount.length > 0 261 | ) { 262 | const ignoredColumns = [ 263 | ...new Set([ 264 | ...excludedColumns, 265 | ...excludedColumnsFromBoardStoryPointsCount, 266 | ]), 267 | ]; 268 | additionalContent = `274 | Board Story Points: ${totalBoardStoryPoints}${additionalContent} 275 |
`; 276 | 277 | boardHeaderNode.prepend(summaryDiv); 278 | } 279 | })(); 280 | --------------------------------------------------------------------------------