diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72f989a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +secrets.json +config.json + +**/venv** + +.vscode + +__pycache__ \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a6b81fd --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright © 2021 Joseph Hale + +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cae5c3 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# Burndown Chart for GitHub Projects +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). + +## Table of Contents +* [Features](#features) +* [Installation](#installation) +* [Assumptions](#assumptions) +* [Usage](#usage) +* [Contributing](#contributing) +* [About](#about) + +## Features +* Create a **burndown chart for a GitHub Project Board**. +* Works for **private repositories**. +* Includes a **trend line** for the current sprint. +* Supports custom labels for **tracking points for issues** + +## Assumptions +This tool, while flexible, makes the following assumptions about your project management workflow: +* 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) +* 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) +* 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) +* 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. + - Furthermore, all labels that indicate point values have the format ``. + - However, multiple labels indicating points on the same Issue are supported. +* 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. + +## Installation +### 0. Clone this repository +``` +git clone https://github.com/jhale1805/github-projects-burndown-chart.git +cd github-projects-burndown-chart +``` +### 1. Create a virtual environment +``` +python -m venv ./venv +``` + +### 2. Activate the virtual environment + +*Linux/Mac OS* +``` +source venv/bin/activate +``` +*Windows (Powershell)* +``` +.\venv\Scripts\activate +``` +*Windows (Command Prompt)* +``` +.\venv\Scripts\activate.bat +``` + +### 3. Install the dependencies +``` +pip install -r requirements.txt +``` + +## Usage +1. Create a [Personal Access Token](https://github.com/settings/tokens) with the `repo` scope. + - Do not share this token with anyone! It gives the bearer full control over all private repositories you have access to! + - This is required to pull the Project Board data from GitHub's GraphQL API. +2. Make a copy of `src/config/secrets.json.dist` without the `.dist` ending. + - This allows the `.gitignore` to exclude your `secrets.json` from being accidentally committed. +3. Fill out the `github_token` with your newly created Personal Access Token. +4. Make a copy of `src/config/config.json.dist` without the `.dist` ending. + - This allows the `.gitignore` to exclude your `config.json` from being accidentally committed. +5. Fill out all the configuration settings + - `repo_owner`: The username of the owner of the repo. + - For example, `jhale1805` + - `repo_name`: The name of the repo. + - For example, `github-projects-burndown-chart` + - `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. + - For example, `1` from [`https://github.com/jhale1805/github-projects-burndown-chart/projects/1`](https://github.com/jhale1805/github-projects-burndown-chart/projects/1) + - `sprint_start_date`: The first day of the sprint. Formatted as `YYYY-MM-DD`. + - Must be entered here since GitHub Project Boards don't have an assigned start/end date. + - For example, `2021-10-08` + - `sprint_end_date`: The last day of the sprint. Formatted as `YYYY-MM-DD`. + - Must be entered here since GitHub Project Boards don't have an assigned start/end date. + - For example, `2021-10-22` + - `points_label`: The prefix for issue labels containing the point value of the issue. Removing this prefix must leave just an integer. + - For example: `Points: ` (with the space) +6. Run `python src/main.py` to generate the burndown chart. + - This will pop up an interactive window containing the burndown chart, including a button for saving it as a picture. + +## Contributing +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). + +*The Legal Part* + +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. + +## About +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. + +We hope it will be especially useful to other students in computing-related fields. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..400574a Binary files /dev/null and b/requirements.txt differ diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/chart/__init__.py b/src/chart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/chart/burndown.py b/src/chart/burndown.py new file mode 100644 index 0000000..951f55c --- /dev/null +++ b/src/chart/burndown.py @@ -0,0 +1,48 @@ +import matplotlib.pyplot as plt +from datetime import datetime + +from config import config +from gh.project import Project + +class BurndownChart: + + def __init__(self, project: Project): + # Initialize important dates + self.start_date = datetime.strptime( + config['sprint_start_date'], + '%Y-%m-%d') + self.end_date = datetime.strptime( + config['sprint_end_date'], + '%Y-%m-%d') + self.project = project + + def render(self): + outstanding_points_by_day = self.project.outstanding_points_by_day( + self.start_date, + self.end_date) + # Load date dict for priority values with x being range of how many days are in sprint + x = list(range(len(outstanding_points_by_day.keys()))) + y = list(outstanding_points_by_day.values()) + + # Plot point values for sprint along xaxis=range yaxis=points over time + plt.plot(x, y) + plt.axline((x[0], self.project.total_points), + slope=-(self.project.total_points/(len(y)-1)), + color="green", + linestyle=(0, (5, 5))) + + # Set sprint beginning + plt.ylim(ymin=0) + plt.xlim(xmin=x[0], xmax=x[-1]) + + # Replace xaxis range for date matching to range value + plt.xticks(x, outstanding_points_by_day.keys()) + plt.xticks(rotation=90) + + # Set titles and labels + plt.title(f"{self.project.name}: Burndown Chart") + plt.ylabel("Outstanding Points") + plt.xlabel("Date") + + # Generate Plot + plt.show() diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..598ae17 --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1,38 @@ +import json +import os +import logging + +# Set up logging +__logger = logging.getLogger(__name__) +__ch = logging.StreamHandler() +__ch.setFormatter( + logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) +__logger.addHandler(__ch) + +# File I/O inspired by https://stackoverflow.com/a/4060259/14765128 +__location__ = os.path.realpath( + os.path.join( + os.getcwd(), + os.path.dirname(__file__))) + +try: + with open(os.path.join(__location__, 'config.json')) as config_json: + config = json.load(config_json) +except FileNotFoundError as err: + __logger.critical(err) + __logger.critical('Please create a config.json file in the config ' + 'directory; this tool cannot generate a burndown chart without it.') + __logger.critical('See the project README.md and config/config.json.dist ' + 'for details.') + exit(1) + +try: + with open(os.path.join(__location__, 'secrets.json')) as secrets_json: + secrets = json.load(secrets_json) +except FileNotFoundError as err: + __logger.critical(err) + __logger.critical('Please create a secrets.json file in the config ' + 'directory; this tool cannot generate a burndown chart without it.') + __logger.critical('See the project README.md and config/secrets.json.dist ' + 'for details.') + exit(1) \ No newline at end of file diff --git a/src/config/config.json.dist b/src/config/config.json.dist new file mode 100644 index 0000000..ae8c190 --- /dev/null +++ b/src/config/config.json.dist @@ -0,0 +1,8 @@ +{ + "repo_owner": "", + "repo_name": "", + "project_number": 1, + "sprint_start_date": "", + "sprint_end_date": "", + "points_label": "" +} \ No newline at end of file diff --git a/src/config/secrets.json.dist b/src/config/secrets.json.dist new file mode 100644 index 0000000..35d9240 --- /dev/null +++ b/src/config/secrets.json.dist @@ -0,0 +1,3 @@ +{ + "github_token": "" +} \ No newline at end of file diff --git a/src/gh/__init__.py b/src/gh/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gh/api_wrapper.py b/src/gh/api_wrapper.py new file mode 100644 index 0000000..316e807 --- /dev/null +++ b/src/gh/api_wrapper.py @@ -0,0 +1,75 @@ +import logging +import requests +from requests.api import head + +from config import secrets +from .project import Project + +# Set up logging +__logger = logging.getLogger(__name__) +__ch = logging.StreamHandler() +__ch.setFormatter( + logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) +__logger.addHandler(__ch) + +project_query = """ +query { + repository(owner: "%(repo_owner)s", name: "%(repo_name)s") { + project(number: %(project_number)d) { + name + columns(first: 5) { + nodes { + name + cards(first: 50) { + nodes { + id + note + state + content { + ... on Issue { + title + createdAt + closedAt + labels(first: 5) { + nodes { + name + } + } + } + } + } + } + } + } + } + } +} +""" # Heavily inspired by https://github.com/radekstepan/burnchart/issues/129#issuecomment-394469442 + +def get_project(repo_owner: str, repo_name: str, project_number: int) -> dict: + query = project_query % { + 'repo_owner': repo_owner, + 'repo_name': repo_name, + 'project_number': project_number} + query_response = gh_api_query(query) + project_data = query_response['data']['repository']['project'] + return Project(project_data) + +def gh_api_query(query: str) -> dict: + headers = {'Authorization': 'bearer %s' % secrets['github_token']} \ + if 'github_token' in secrets else {} + response = requests.post( + 'https://api.github.com/graphql', + headers=headers, + json={'query': query}).json() + # Gracefully report failures due to bad credentials + if response.get('message') and response['message'] == 'Bad credentials': + __logger.critical(response['message']) + __logger.critical('Failed to extract project data from GitHub due ' + 'to an invalid access token.') + __logger.critical('Please set the `github_token` key in the ' + '`src/secrets.json` file to a valid access token with access ' + 'to the repo specified in the `src/config.json` file.') + exit(1) + return response + \ No newline at end of file diff --git a/src/gh/project.py b/src/gh/project.py new file mode 100644 index 0000000..9464f80 --- /dev/null +++ b/src/gh/project.py @@ -0,0 +1,91 @@ +from datetime import datetime, timedelta + +from config import config + + +class Project: + def __init__(self, project_data): + self.name = project_data['name'] + self.columns = self.__parse_columns(project_data) + + def __parse_columns(self, project_data): + columns_data = project_data['columns']['nodes'] + columns = [Column(column_data) for column_data in columns_data] + return columns + + @property + def total_points(self): + return sum([column.get_total_points() for column in self.columns]) + + def points_completed_by_date(self, start_date, end_date): + points_completed_by_date = { + str(date)[:10] : 0 + for date in [ + start_date + timedelta(days=x) + for x in range(0, (end_date - start_date).days + 1) + ] + } + for column in self.columns: + for card in column.cards: + if card.closedAt: + date_str = str(card.closedAt)[:10] + points_completed_by_date[date_str] += card.points + return points_completed_by_date + + def outstanding_points_by_day(self, start_date, end_date): + outstanding_points_by_day = {} + points_completed = 0 + points_completed_by_date = self.points_completed_by_date(start_date, end_date) + current_date = datetime.now() + for date in points_completed_by_date: + points_completed += points_completed_by_date[date] + if datetime.strptime(date, '%Y-%m-%d') < current_date: + outstanding_points_by_day[date] = self.total_points - points_completed + else: + outstanding_points_by_day[date] = None + return outstanding_points_by_day + + +class Column: + def __init__(self, column_data): + self.cards = self.__parse_cards(column_data) + + def __parse_cards(self, column_data): + cards_data = column_data['cards']['nodes'] + cards = [Card(card_data) for card_data in cards_data] + return cards + + def get_total_points(self): + return sum([card.points for card in self.cards]) + + +class Card: + def __init__(self, card_data): + card_data = card_data['content'] if card_data['content'] else card_data + self.createdAt = self.__parse_createdAt(card_data) + self.closedAt = self.__parse_closedAt(card_data) + self.points = self.__parse_points(card_data) + + def __parse_createdAt(self, card_data): + createdAt = None + if card_data.get('createdAt'): + createdAt = datetime.strptime( + card_data['createdAt'][:10], + '%Y-%m-%d') + return createdAt + + def __parse_closedAt(self, card_data): + closedAt = None + if card_data.get('closedAt'): + closedAt = datetime.strptime( + card_data['closedAt'][:10], + '%Y-%m-%d') + return closedAt + + def __parse_points(self, card_data): + card_points = 0 + card_labels = card_data.get('labels', {"nodes": []})['nodes'] + for label in card_labels: + if config['points_label'] in label['name']: + card_points += int(label['name'][len(config['points_label']):]) + return card_points \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..032adfd --- /dev/null +++ b/src/main.py @@ -0,0 +1,12 @@ +from chart.burndown import BurndownChart +from config import config +from gh.api_wrapper import get_project + +if __name__ == '__main__': + project = get_project( + config['repo_owner'], + config['repo_name'], + config['project_number']) + burndown_chart = BurndownChart(project) + burndown_chart.render() + print('Done') \ No newline at end of file