Skip to content

Commit

Permalink
Add support for custom point calculators
Browse files Browse the repository at this point in the history
Different people will want to calculate the points for their
burndown charts in different ways.

This commit breaks a lot of cross module dependencies to add
support for a custom `PointsCalculator` interface, which follows
the Strategy design pattern for calculating the points over time
to show on the burndown chart.

Four calculators are made available by default (see `README.md`).
Additional calculators may be added over time or by PR.

Closes #23 by providing the `taiga` calculator.
  • Loading branch information
thehale committed Jan 22, 2022
1 parent 3659933 commit edc74e1
Show file tree
Hide file tree
Showing 13 changed files with 323 additions and 115 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ This allows the `.gitignore` to exclude your `config.json` from being accidental
| `sprint_end_date` | The last day of the sprint formatted as `YYYY-MM-DD`. <br/><br/> Must be entered here since GitHub Project Boards don't have an assigned start/end date. <br/><br/> Example: `2021-10-21` |
| `chart_end_date` | (OPTIONAL) The last day to show on the burndown chart formatted as `YYYY-MM-DD`. <br/><br/> Used to change the end date of the chart without affecting the slope of the ideal burndown line (e.g. to show tasks that were completed after the official end of a sprint). <br/><br/> Example: `2021-10-24` |
| `points_label` | (OPTIONAL) The prefix for issue labels containing the point value of the issue. Removing this prefix must leave just an integer. If set to `null`, the burndown chart will count open issues instead of points.<br/><br/> Example: `Points: ` (with the space) |
| `calculators` | (OPTIONAL) A list of the calculator(s) to use to calculate the point burndown lines to show on the burndown chart. (DEFAULT: [`closed`])<br/><br/>_OPTIONS:_ `closed`, `assigned`, `created`, `taiga`<br/><br/> Example: [`taiga`, `closed`, `assigned`] |

#### Organization Projects
All settings are the same as for the [Repository Projects](#repository-projects), except `repo_owner` and `repo_name` are replaced with `organization_name` as shown below.
Expand Down
Binary file modified docs/images/example_burndown_chart.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
109 changes: 67 additions & 42 deletions src/github_projects_burndown_chart/chart/burndown.py
Original file line number Diff line number Diff line change
@@ -1,58 +1,83 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, Iterable
import matplotlib.pyplot as plt
import os

from config import config
from gh.project import Project
from util.dates import parse_to_local, parse_to_utc
from util.dates import parse_to_local, date_range


class BurndownChart:
@dataclass
class BurndownChartDataSeries:
name: str
data: Iterable[Dict[datetime, int]]
format: Dict[str, Any]


def default_ideal_trendline_format() -> Dict[str, Any]:
return dict(
color="grey",
linestyle=(0, (5, 5))
)


@dataclass
class BurndownChartData:
sprint_name: str
utc_chart_start: datetime
utc_chart_end: datetime
utc_sprint_start: datetime
utc_sprint_end: datetime
total_points: int
series: Iterable[BurndownChartDataSeries]
points_label: str = "Outstanding Points"
ideal_trendline_format: Dict[str, Any] = field(
default_factory=default_ideal_trendline_format)


def __init__(self, project: Project):
self.start_date_utc: datetime = parse_to_utc(
config['settings']['sprint_start_date'])
self.end_date_utc: datetime = parse_to_utc(
config['settings']['sprint_end_date'])
self.chart_end_date_utc: datetime = parse_to_utc(
config['settings']['chart_end_date']) \
if config['settings'].get('chart_end_date') else None
class BurndownChart:

self.project: Project = project
def __init__(self, data: BurndownChartData):
self.data: BurndownChartData = data

def __prepare_chart(self):
end_date = self.chart_end_date_utc if self.chart_end_date_utc else self.end_date_utc
outstanding_points_by_day = self.project.outstanding_points_by_date(
self.start_date_utc,
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())
sprint_days = (self.end_date_utc - self.start_date_utc).days

# 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/(sprint_days)),
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
date_labels = [str(parse_to_local(date))[:10]
for date in outstanding_points_by_day.keys()]
plt.xticks(x, date_labels)
plt.xticks(rotation=90)
# Plot the data
chart_dates = date_range(
self.data.utc_chart_start, self.data.utc_chart_end)
for series in self.data.series:
series_dates = [chart_dates.index(date)
for date in series.data.keys()]
series_points = list(series.data.values())
plt.plot(
series_dates,
series_points,
label=series.name,
**series.format
)
plt.legend()

# Set titles and labels
plt.title(f"{self.project.name}: Burndown Chart")
points_label = config['settings']['points_label']
plt.ylabel(f"Outstanding {'Points' if points_label else 'Issues'}")
# Configure title and labels
plt.title(f"{self.data.sprint_name}: Burndown Chart")
plt.ylabel(self.data.points_label)
plt.xlabel("Date")

# Configure axes limits
plt.ylim(ymin=0, ymax=self.data.total_points * 1.1)
plt.xlim(xmin=chart_dates.index(self.data.utc_chart_start),
xmax=chart_dates.index(self.data.utc_chart_end))

# Configure x-axis tick marks
date_labels = [str(parse_to_local(date))[:10] for date in chart_dates]
plt.xticks(range(len(chart_dates)), date_labels)
plt.xticks(rotation=90)

# Plot the ideal trendline
sprint_days = (self.data.utc_sprint_end -
self.data.utc_sprint_start).days
plt.axline((chart_dates.index(self.data.utc_sprint_start), self.data.total_points),
slope=-(self.data.total_points/(sprint_days)),
**self.data.ideal_trendline_format)

def generate_chart(self, path):
self.__prepare_chart()
if not os.path.exists(path):
Expand Down
16 changes: 16 additions & 0 deletions src/github_projects_burndown_chart/config/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from datetime import datetime
import json
import os
import logging

from util.dates import parse_to_utc

# Set up logging
__logger = logging.getLogger(__name__)
__ch = logging.StreamHandler()
Expand Down Expand Up @@ -39,13 +42,26 @@ def set_project(self, project_type: str, project_name: str):
self.project_type = project_type
self.project_name = project_name

def utc_sprint_start(self) -> datetime:
return self.__get_date('sprint_start_date')

def utc_sprint_end(self) -> datetime:
return self.__get_date('sprint_end_date')

def utc_chart_end(self) -> datetime:
return self.__get_date('chart_end_date')

def __getitem__(self, key: str):
if not hasattr(self, 'project_type'):
raise AttributeError('No project has been set.')
if not hasattr(self, 'project_name'):
raise AttributeError('No project has been set.')
return self.raw_config[self.project_type][self.project_name][key]

def __get_date(self, name: str) -> datetime:
date = self['settings'].get(name)
return parse_to_utc(date) if date else None


config = Config(__config)

Expand Down
43 changes: 41 additions & 2 deletions src/github_projects_burndown_chart/gh/api_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import logging
import os
import requests
from requests.api import head
from datetime import date
import hashlib
import json
import tempfile

from config import config, secrets
from .project import Project
Expand Down Expand Up @@ -29,13 +33,25 @@ def get_organization_project() -> dict:


def gh_api_query(query: str, variables: dict) -> dict:
response = __get_from_cache(query, variables)
if not response:
response = __get_from_api(query, variables)
__cache_response(query, variables, response)
return response


def prepare_payload(query, variables):
return {'query': query, 'variables': variables}


def __get_from_api(query, variables):
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, 'variables': variables}).json()
json=prepare_payload(query, variables)).json()

# Gracefully report failures due to bad credentials
if response.get('message') and response['message'] == 'Bad credentials':
Expand All @@ -53,3 +69,26 @@ def gh_api_query(query: str, variables: dict) -> dict:
__logger.critical(response['errors'])
exit(1)
return response


def __get_from_cache(query, variables):
temp_path = __temp_path(query, variables)
if os.path.exists(temp_path):
with open(temp_path, 'r') as f:
return json.load(f)
return None


def __cache_response(query, variables, response):
temp_path = __temp_path(query, variables)
with open(temp_path, 'w') as f:
json.dump(response, f)


def __temp_path(query, variables):
temp_dir = tempfile.gettempdir()
payload = prepare_payload(query, variables)
payload.update({'today': str(date.today())})
filename = f"{hashlib.sha256(json.dumps(payload).encode('utf-8')).hexdigest()}.json"
temp_path = os.path.join(temp_dir, filename)
return temp_path
71 changes: 17 additions & 54 deletions src/github_projects_burndown_chart/gh/project.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
from datetime import datetime, timedelta
from typing import Dict
from datetime import datetime
from dateutil.parser import isoparse

from config import config
from util.dates import TODAY_UTC


class Project:
Expand All @@ -20,52 +18,9 @@ def __parse_columns(self, project_data):
def total_points(self):
return sum([column.get_total_points() for column in self.columns])

def points_completed_by_date(self, start_date: datetime, end_date: datetime) -> Dict[datetime, int]:
"""Computes the number of points completed by date.
Basically the data behind a burnup chart for the given date range.
Args:
start_date (datetime): The start date of the chart in UTC.
end_date (datetime): The end date of the chart in UTC.
Returns:
Dict[datetime, int]: A dictionary of date and points completed.
"""
points_completed_by_date = {}

cards = [card for column in self.columns for card in column.cards]
completed_cards = [card for card in cards if card.closedAt is not None]
sprint_dates = [start_date + timedelta(days=x)
# The +1 includes the end_date in the list
for x in range(0, (end_date - start_date).days + 1)]
for date in sprint_dates:
# Get the issues completed before midnight on the given date.
date_23_59 = date + timedelta(hours=23, minutes=59)
cards_done_by_date = [card for card in completed_cards
if card.closedAt <= date_23_59]
points_completed_by_date[date] = sum([card.points for card
in cards_done_by_date])
return points_completed_by_date

def outstanding_points_by_date(self, start_date: datetime, end_date: datetime) -> Dict[datetime, int]:
"""Computes the number of points remaining to be completed by date.
Basically the data behind a burndown chart for the given date range.
Args:
start_date (datetime): The start date of the chart in UTC.
end_date (datetime): The end date of the chart in UTC.
Returns:
Dict[datetime, int]: A dictionary of date and points remaining.
"""
points_completed_by_date = self.points_completed_by_date(
start_date, end_date)
today_23_59 = TODAY_UTC + timedelta(hours=23, minutes=59)
return {
date: self.total_points - points_completed_by_date[date]
if date <= today_23_59 else None
for date in points_completed_by_date
}
@property
def cards(self):
return [card for column in self.columns for card in column.cards]


class Column:
Expand All @@ -84,23 +39,31 @@ def get_total_points(self):
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.created: datetime = self.__parse_createdAt(card_data)
self.assigned: datetime = self.__parse_assignedAt(card_data)
self.closed: datetime = self.__parse_closedAt(card_data)
self.points = self.__parse_points(card_data)

def __parse_createdAt(self, card_data):
def __parse_assignedAt(self, card_data) -> datetime:
assignedAt = None
assignedDates = card_data.get('timelineItems', {}).get('nodes', [])
if assignedDates:
assignedAt = isoparse(assignedDates[0]['createdAt'])
return assignedAt

def __parse_createdAt(self, card_data) -> datetime:
createdAt = None
if card_data.get('createdAt'):
createdAt = isoparse(card_data['createdAt'])
return createdAt

def __parse_closedAt(self, card_data):
def __parse_closedAt(self, card_data) -> datetime:
closedAt = None
if card_data.get('closedAt'):
closedAt = isoparse(card_data['closedAt'])
return closedAt

def __parse_points(self, card_data):
def __parse_points(self, card_data) -> int:
card_points = 0
points_label = config['settings']['points_label']
if not points_label:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ query OrganizationProject($organization_name: String!, $project_number: Int!, $c
content {
... on Issue {
title
timelineItems(first: 20, itemTypes: [ASSIGNED_EVENT]) {
nodes {
__typename
... on AssignedEvent {
createdAt
}
}
}
createdAt
closedAt
labels(first: $labels_per_issue_count) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# Heavily inspired by https://github.com/radekstepan/burnchart/issues/129#issuecomment-394469442
query RepositoryProject($repo_owner: String!, $repo_name: String!, $project_number: Int!, $column_count: Int!, $max_cards_per_column_count: Int!, $labels_per_issue_count: Int!) {
repository(owner: $repo_owner, name: $repo_name) {
project(number: $project_number) {
Expand All @@ -15,6 +14,14 @@ query RepositoryProject($repo_owner: String!, $repo_name: String!, $project_numb
content {
... on Issue {
title
timelineItems(first: 20, itemTypes: [ASSIGNED_EVENT]) {
nodes {
__typename
... on AssignedEvent {
createdAt
}
}
}
createdAt
closedAt
labels(first: $labels_per_issue_count) {
Expand All @@ -30,4 +37,4 @@ query RepositoryProject($repo_owner: String!, $repo_name: String!, $project_numb
}
}
}
}
}
Loading

0 comments on commit edc74e1

Please sign in to comment.