An easy-to-use burndown chart generator for GitHub Project Boards.
0

Configure Feed

Select the types of activity you want to include in your feed.

Create burndown charts for GitHub Project Boards

This tool enables you to generate a Burndown Chart for your
progress on a GitHub Project Board. See the README.md for
full details!

Closes #1
Closes #2
Closes #3

Co-authored-by: Jacob Janes <44354744+jgjanes@users.noreply.github.com>

Joseph Hale (Oct 8, 2021, 2:09 AM -0700) 930893f4

+386
+8
.gitignore
··· 1 + secrets.json 2 + config.json 3 + 4 + **/venv** 5 + 6 + .vscode 7 + 8 + __pycache__
+7
LICENSE.md
··· 1 + Copyright © 2021 Joseph Hale 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 + 5 + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 + 7 + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+96
README.md
··· 1 + # Burndown Chart for GitHub Projects 2 + An easy to use [burndown chart](https://www.scrum.org/resources/scrum-glossary#:~:text=B-,burn-down%20chart,-%3A%C2%A0a%20chart%20which) generator for [GitHub Project Boards](https://docs.github.com/en/issues/organizing-your-work-with-project-boards/managing-project-boards/about-project-boards). 3 + 4 + ## Table of Contents 5 + * [Features](#features) 6 + * [Installation](#installation) 7 + * [Assumptions](#assumptions) 8 + * [Usage](#usage) 9 + * [Contributing](#contributing) 10 + * [About](#about) 11 + 12 + ## Features 13 + * Create a **burndown chart for a GitHub Project Board**. 14 + * Works for **private repositories**. 15 + * Includes a **trend line** for the current sprint. 16 + * Supports custom labels for **tracking points for issues** 17 + 18 + ## Assumptions 19 + This tool, while flexible, makes the following assumptions about your project management workflow: 20 + * You use one and only one [GitHub Project Board](https://docs.github.com/en/issues/organizing-your-work-with-project-boards/managing-project-boards/about-project-boards) for each of your [Sprints](https://scrumguides.org/scrum-guide.html#the-sprint) 21 + * You use one and only one [GitHub Milestone](https://docs.github.com/en/issues/using-labels-and-milestones-to-track-work/about-milestones) for each of your [User Stories](https://www.scrum.org/resources/blog/user-story-or-stakeholder-story) 22 + * You use one and only one [GitHub Issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/about-issues) for each of your [Sprint Backlog Items/Tasks](https://scrumguides.org/scrum-guide.html#sprint-backlog) 23 + * Each of your GitHub Issues has a [label](https://docs.github.com/en/issues/using-labels-and-milestones-to-track-work/managing-labels) indicating how many [points](https://www.scrum.org/resources/scrum-glossary#:~:text=several%20ways%20such%20as-,user%20story%20points,-or%20task%20hours.%20Work) its corresponding task is worth. 24 + - Furthermore, all labels that indicate point values have the format `<prefix><int>`. 25 + - However, multiple labels indicating points on the same Issue are supported. 26 + * A Sprint Backlog Task is considered [Done](https://www.scrum.org/resources/professional-scrum-developer-glossary#:~:text=D-,definition%20of%20done%3A,-a%20shared%20understanding) if its corresponding GitHub Issue is Closed. 27 + 28 + ## Installation 29 + ### 0. Clone this repository 30 + ``` 31 + git clone https://github.com/jhale1805/github-projects-burndown-chart.git 32 + cd github-projects-burndown-chart 33 + ``` 34 + ### 1. Create a virtual environment 35 + ``` 36 + python -m venv ./venv 37 + ``` 38 + 39 + ### 2. Activate the virtual environment 40 + 41 + *Linux/Mac OS* 42 + ``` 43 + source venv/bin/activate 44 + ``` 45 + *Windows (Powershell)* 46 + ``` 47 + .\venv\Scripts\activate 48 + ``` 49 + *Windows (Command Prompt)* 50 + ``` 51 + .\venv\Scripts\activate.bat 52 + ``` 53 + 54 + ### 3. Install the dependencies 55 + ``` 56 + pip install -r requirements.txt 57 + ``` 58 + 59 + ## Usage 60 + 1. Create a [Personal Access Token](https://github.com/settings/tokens) with the `repo` scope. 61 + - Do not share this token with anyone! It gives the bearer full control over all private repositories you have access to! 62 + - This is required to pull the Project Board data from GitHub's GraphQL API. 63 + 2. Make a copy of `src/config/secrets.json.dist` without the `.dist` ending. 64 + - This allows the `.gitignore` to exclude your `secrets.json` from being accidentally committed. 65 + 3. Fill out the `github_token` with your newly created Personal Access Token. 66 + 4. Make a copy of `src/config/config.json.dist` without the `.dist` ending. 67 + - This allows the `.gitignore` to exclude your `config.json` from being accidentally committed. 68 + 5. Fill out all the configuration settings 69 + - `repo_owner`: The username of the owner of the repo. 70 + - For example, `jhale1805` 71 + - `repo_name`: The name of the repo. 72 + - For example, `github-projects-burndown-chart` 73 + - `project_number`: The id of the project for which you want to generate a burndown chart. This is found in the URL when looking at the project board on GitHub. 74 + - For example, `1` from [`https://github.com/jhale1805/github-projects-burndown-chart/projects/1`](https://github.com/jhale1805/github-projects-burndown-chart/projects/1) 75 + - `sprint_start_date`: The first day of the sprint. Formatted as `YYYY-MM-DD`. 76 + - Must be entered here since GitHub Project Boards don't have an assigned start/end date. 77 + - For example, `2021-10-08` 78 + - `sprint_end_date`: The last day of the sprint. Formatted as `YYYY-MM-DD`. 79 + - Must be entered here since GitHub Project Boards don't have an assigned start/end date. 80 + - For example, `2021-10-22` 81 + - `points_label`: The prefix for issue labels containing the point value of the issue. Removing this prefix must leave just an integer. 82 + - For example: `Points: ` (with the space) 83 + 6. Run `python src/main.py` to generate the burndown chart. 84 + - This will pop up an interactive window containing the burndown chart, including a button for saving it as a picture. 85 + 86 + ## Contributing 87 + Contributions are welcome via a [Pull Request](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). 88 + 89 + *The Legal Part* 90 + 91 + By submitting a contribution, you are agreeing that the full contents of your contribution will be subject to the license terms governing this repository, and you are affirming that you have the legal right to subject your contribution to these terms. 92 + 93 + ## About 94 + This project was first created by Joseph Hale (@jhale1805) and Jacob Janes (@jgjanes) to facilitate their coursework in the BS Software Engineering degree program at Arizona State University. 95 + 96 + We hope it will be especially useful to other students in computing-related fields.
requirements.txt

This is a binary file and will not be displayed.

src/__init__.py
+12
src/main.py
··· 1 + from chart.burndown import BurndownChart 2 + from config import config 3 + from gh.api_wrapper import get_project 4 + 5 + if __name__ == '__main__': 6 + project = get_project( 7 + config['repo_owner'], 8 + config['repo_name'], 9 + config['project_number']) 10 + burndown_chart = BurndownChart(project) 11 + burndown_chart.render() 12 + print('Done')
src/chart/__init__.py
+48
src/chart/burndown.py
··· 1 + import matplotlib.pyplot as plt 2 + from datetime import datetime 3 + 4 + from config import config 5 + from gh.project import Project 6 + 7 + class BurndownChart: 8 + 9 + def __init__(self, project: Project): 10 + # Initialize important dates 11 + self.start_date = datetime.strptime( 12 + config['sprint_start_date'], 13 + '%Y-%m-%d') 14 + self.end_date = datetime.strptime( 15 + config['sprint_end_date'], 16 + '%Y-%m-%d') 17 + self.project = project 18 + 19 + def render(self): 20 + outstanding_points_by_day = self.project.outstanding_points_by_day( 21 + self.start_date, 22 + self.end_date) 23 + # Load date dict for priority values with x being range of how many days are in sprint 24 + x = list(range(len(outstanding_points_by_day.keys()))) 25 + y = list(outstanding_points_by_day.values()) 26 + 27 + # Plot point values for sprint along xaxis=range yaxis=points over time 28 + plt.plot(x, y) 29 + plt.axline((x[0], self.project.total_points), 30 + slope=-(self.project.total_points/(len(y)-1)), 31 + color="green", 32 + linestyle=(0, (5, 5))) 33 + 34 + # Set sprint beginning 35 + plt.ylim(ymin=0) 36 + plt.xlim(xmin=x[0], xmax=x[-1]) 37 + 38 + # Replace xaxis range for date matching to range value 39 + plt.xticks(x, outstanding_points_by_day.keys()) 40 + plt.xticks(rotation=90) 41 + 42 + # Set titles and labels 43 + plt.title(f"{self.project.name}: Burndown Chart") 44 + plt.ylabel("Outstanding Points") 45 + plt.xlabel("Date") 46 + 47 + # Generate Plot 48 + plt.show()
+38
src/config/__init__.py
··· 1 + import json 2 + import os 3 + import logging 4 + 5 + # Set up logging 6 + __logger = logging.getLogger(__name__) 7 + __ch = logging.StreamHandler() 8 + __ch.setFormatter( 9 + logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) 10 + __logger.addHandler(__ch) 11 + 12 + # File I/O inspired by https://stackoverflow.com/a/4060259/14765128 13 + __location__ = os.path.realpath( 14 + os.path.join( 15 + os.getcwd(), 16 + os.path.dirname(__file__))) 17 + 18 + try: 19 + with open(os.path.join(__location__, 'config.json')) as config_json: 20 + config = json.load(config_json) 21 + except FileNotFoundError as err: 22 + __logger.critical(err) 23 + __logger.critical('Please create a config.json file in the config ' 24 + 'directory; this tool cannot generate a burndown chart without it.') 25 + __logger.critical('See the project README.md and config/config.json.dist ' 26 + 'for details.') 27 + exit(1) 28 + 29 + try: 30 + with open(os.path.join(__location__, 'secrets.json')) as secrets_json: 31 + secrets = json.load(secrets_json) 32 + except FileNotFoundError as err: 33 + __logger.critical(err) 34 + __logger.critical('Please create a secrets.json file in the config ' 35 + 'directory; this tool cannot generate a burndown chart without it.') 36 + __logger.critical('See the project README.md and config/secrets.json.dist ' 37 + 'for details.') 38 + exit(1)
+8
src/config/config.json.dist
··· 1 + { 2 + "repo_owner": "", 3 + "repo_name": "", 4 + "project_number": 1, 5 + "sprint_start_date": "", 6 + "sprint_end_date": "", 7 + "points_label": "" 8 + }
+3
src/config/secrets.json.dist
··· 1 + { 2 + "github_token": "" 3 + }
src/gh/__init__.py
+75
src/gh/api_wrapper.py
··· 1 + import logging 2 + import requests 3 + from requests.api import head 4 + 5 + from config import secrets 6 + from .project import Project 7 + 8 + # Set up logging 9 + __logger = logging.getLogger(__name__) 10 + __ch = logging.StreamHandler() 11 + __ch.setFormatter( 12 + logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) 13 + __logger.addHandler(__ch) 14 + 15 + project_query = """ 16 + query { 17 + repository(owner: "%(repo_owner)s", name: "%(repo_name)s") { 18 + project(number: %(project_number)d) { 19 + name 20 + columns(first: 5) { 21 + nodes { 22 + name 23 + cards(first: 50) { 24 + nodes { 25 + id 26 + note 27 + state 28 + content { 29 + ... on Issue { 30 + title 31 + createdAt 32 + closedAt 33 + labels(first: 5) { 34 + nodes { 35 + name 36 + } 37 + } 38 + } 39 + } 40 + } 41 + } 42 + } 43 + } 44 + } 45 + } 46 + } 47 + """ # Heavily inspired by https://github.com/radekstepan/burnchart/issues/129#issuecomment-394469442 48 + 49 + def get_project(repo_owner: str, repo_name: str, project_number: int) -> dict: 50 + query = project_query % { 51 + 'repo_owner': repo_owner, 52 + 'repo_name': repo_name, 53 + 'project_number': project_number} 54 + query_response = gh_api_query(query) 55 + project_data = query_response['data']['repository']['project'] 56 + return Project(project_data) 57 + 58 + def gh_api_query(query: str) -> dict: 59 + headers = {'Authorization': 'bearer %s' % secrets['github_token']} \ 60 + if 'github_token' in secrets else {} 61 + response = requests.post( 62 + 'https://api.github.com/graphql', 63 + headers=headers, 64 + json={'query': query}).json() 65 + # Gracefully report failures due to bad credentials 66 + if response.get('message') and response['message'] == 'Bad credentials': 67 + __logger.critical(response['message']) 68 + __logger.critical('Failed to extract project data from GitHub due ' 69 + 'to an invalid access token.') 70 + __logger.critical('Please set the `github_token` key in the ' 71 + '`src/secrets.json` file to a valid access token with access ' 72 + 'to the repo specified in the `src/config.json` file.') 73 + exit(1) 74 + return response 75 +
+91
src/gh/project.py
··· 1 + from datetime import datetime, timedelta 2 + 3 + from config import config 4 + 5 + 6 + class Project: 7 + def __init__(self, project_data): 8 + self.name = project_data['name'] 9 + self.columns = self.__parse_columns(project_data) 10 + 11 + def __parse_columns(self, project_data): 12 + columns_data = project_data['columns']['nodes'] 13 + columns = [Column(column_data) for column_data in columns_data] 14 + return columns 15 + 16 + @property 17 + def total_points(self): 18 + return sum([column.get_total_points() for column in self.columns]) 19 + 20 + def points_completed_by_date(self, start_date, end_date): 21 + points_completed_by_date = { 22 + str(date)[:10] : 0 23 + for date in [ 24 + start_date + timedelta(days=x) 25 + for x in range(0, (end_date - start_date).days + 1) 26 + ] 27 + } 28 + for column in self.columns: 29 + for card in column.cards: 30 + if card.closedAt: 31 + date_str = str(card.closedAt)[:10] 32 + points_completed_by_date[date_str] += card.points 33 + return points_completed_by_date 34 + 35 + def outstanding_points_by_day(self, start_date, end_date): 36 + outstanding_points_by_day = {} 37 + points_completed = 0 38 + points_completed_by_date = self.points_completed_by_date(start_date, end_date) 39 + current_date = datetime.now() 40 + for date in points_completed_by_date: 41 + points_completed += points_completed_by_date[date] 42 + if datetime.strptime(date, '%Y-%m-%d') < current_date: 43 + outstanding_points_by_day[date] = self.total_points - points_completed 44 + else: 45 + outstanding_points_by_day[date] = None 46 + return outstanding_points_by_day 47 + 48 + 49 + class Column: 50 + def __init__(self, column_data): 51 + self.cards = self.__parse_cards(column_data) 52 + 53 + def __parse_cards(self, column_data): 54 + cards_data = column_data['cards']['nodes'] 55 + cards = [Card(card_data) for card_data in cards_data] 56 + return cards 57 + 58 + def get_total_points(self): 59 + return sum([card.points for card in self.cards]) 60 + 61 + 62 + class Card: 63 + def __init__(self, card_data): 64 + card_data = card_data['content'] if card_data['content'] else card_data 65 + self.createdAt = self.__parse_createdAt(card_data) 66 + self.closedAt = self.__parse_closedAt(card_data) 67 + self.points = self.__parse_points(card_data) 68 + 69 + def __parse_createdAt(self, card_data): 70 + createdAt = None 71 + if card_data.get('createdAt'): 72 + createdAt = datetime.strptime( 73 + card_data['createdAt'][:10], 74 + '%Y-%m-%d') 75 + return createdAt 76 + 77 + def __parse_closedAt(self, card_data): 78 + closedAt = None 79 + if card_data.get('closedAt'): 80 + closedAt = datetime.strptime( 81 + card_data['closedAt'][:10], 82 + '%Y-%m-%d') 83 + return closedAt 84 + 85 + def __parse_points(self, card_data): 86 + card_points = 0 87 + card_labels = card_data.get('labels', {"nodes": []})['nodes'] 88 + for label in card_labels: 89 + if config['points_label'] in label['name']: 90 + card_points += int(label['name'][len(config['points_label']):]) 91 + return card_points