Skip to content

Commit

Permalink
Create burndown charts for GitHub Project Boards
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
thehale and jgjanes committed Oct 8, 2021
0 parents commit 930893f
Show file tree
Hide file tree
Showing 14 changed files with 386 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
secrets.json
config.json

**/venv**

.vscode

__pycache__
7 changes: 7 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -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.
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 `<prefix><int>`.
- 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.
Binary file added requirements.txt
Binary file not shown.
Empty file added src/__init__.py
Empty file.
Empty file added src/chart/__init__.py
Empty file.
48 changes: 48 additions & 0 deletions src/chart/burndown.py
Original file line number Diff line number Diff line change
@@ -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()
38 changes: 38 additions & 0 deletions src/config/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions src/config/config.json.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"repo_owner": "",
"repo_name": "",
"project_number": 1,
"sprint_start_date": "",
"sprint_end_date": "",
"points_label": ""
}
3 changes: 3 additions & 0 deletions src/config/secrets.json.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"github_token": ""
}
Empty file added src/gh/__init__.py
Empty file.
75 changes: 75 additions & 0 deletions src/gh/api_wrapper.py
Original file line number Diff line number Diff line change
@@ -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

91 changes: 91 additions & 0 deletions src/gh/project.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 930893f

Please sign in to comment.