Skip to content

Commit edc74e1

Browse files
committed
Add support for custom point calculators
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.
1 parent 3659933 commit edc74e1

File tree

13 files changed

+323
-115
lines changed

13 files changed

+323
-115
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ This allows the `.gitignore` to exclude your `config.json` from being accidental
9090
| `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` |
9191
| `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` |
9292
| `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) |
93+
| `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`] |
9394

9495
#### Organization Projects
9596
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.
0 Bytes
Loading

src/github_projects_burndown_chart/chart/burndown.py

Lines changed: 67 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,83 @@
1+
from dataclasses import dataclass, field
12
from datetime import datetime
3+
from typing import Any, Dict, Iterable
24
import matplotlib.pyplot as plt
35
import os
46

5-
from config import config
6-
from gh.project import Project
7-
from util.dates import parse_to_local, parse_to_utc
7+
from util.dates import parse_to_local, date_range
88

99

10-
class BurndownChart:
10+
@dataclass
11+
class BurndownChartDataSeries:
12+
name: str
13+
data: Iterable[Dict[datetime, int]]
14+
format: Dict[str, Any]
15+
16+
17+
def default_ideal_trendline_format() -> Dict[str, Any]:
18+
return dict(
19+
color="grey",
20+
linestyle=(0, (5, 5))
21+
)
22+
23+
24+
@dataclass
25+
class BurndownChartData:
26+
sprint_name: str
27+
utc_chart_start: datetime
28+
utc_chart_end: datetime
29+
utc_sprint_start: datetime
30+
utc_sprint_end: datetime
31+
total_points: int
32+
series: Iterable[BurndownChartDataSeries]
33+
points_label: str = "Outstanding Points"
34+
ideal_trendline_format: Dict[str, Any] = field(
35+
default_factory=default_ideal_trendline_format)
36+
1137

12-
def __init__(self, project: Project):
13-
self.start_date_utc: datetime = parse_to_utc(
14-
config['settings']['sprint_start_date'])
15-
self.end_date_utc: datetime = parse_to_utc(
16-
config['settings']['sprint_end_date'])
17-
self.chart_end_date_utc: datetime = parse_to_utc(
18-
config['settings']['chart_end_date']) \
19-
if config['settings'].get('chart_end_date') else None
38+
class BurndownChart:
2039

21-
self.project: Project = project
40+
def __init__(self, data: BurndownChartData):
41+
self.data: BurndownChartData = data
2242

2343
def __prepare_chart(self):
24-
end_date = self.chart_end_date_utc if self.chart_end_date_utc else self.end_date_utc
25-
outstanding_points_by_day = self.project.outstanding_points_by_date(
26-
self.start_date_utc,
27-
end_date)
28-
# Load date dict for priority values with x being range of how many days are in sprint
29-
x = list(range(len(outstanding_points_by_day.keys())))
30-
y = list(outstanding_points_by_day.values())
31-
sprint_days = (self.end_date_utc - self.start_date_utc).days
32-
33-
# Plot point values for sprint along xaxis=range yaxis=points over time
34-
plt.plot(x, y)
35-
plt.axline((x[0], self.project.total_points),
36-
slope=-(self.project.total_points/(sprint_days)),
37-
color="green",
38-
linestyle=(0, (5, 5)))
39-
40-
# Set sprint beginning
41-
plt.ylim(ymin=0)
42-
plt.xlim(xmin=x[0], xmax=x[-1])
43-
44-
# Replace xaxis range for date matching to range value
45-
date_labels = [str(parse_to_local(date))[:10]
46-
for date in outstanding_points_by_day.keys()]
47-
plt.xticks(x, date_labels)
48-
plt.xticks(rotation=90)
44+
# Plot the data
45+
chart_dates = date_range(
46+
self.data.utc_chart_start, self.data.utc_chart_end)
47+
for series in self.data.series:
48+
series_dates = [chart_dates.index(date)
49+
for date in series.data.keys()]
50+
series_points = list(series.data.values())
51+
plt.plot(
52+
series_dates,
53+
series_points,
54+
label=series.name,
55+
**series.format
56+
)
57+
plt.legend()
4958

50-
# Set titles and labels
51-
plt.title(f"{self.project.name}: Burndown Chart")
52-
points_label = config['settings']['points_label']
53-
plt.ylabel(f"Outstanding {'Points' if points_label else 'Issues'}")
59+
# Configure title and labels
60+
plt.title(f"{self.data.sprint_name}: Burndown Chart")
61+
plt.ylabel(self.data.points_label)
5462
plt.xlabel("Date")
5563

64+
# Configure axes limits
65+
plt.ylim(ymin=0, ymax=self.data.total_points * 1.1)
66+
plt.xlim(xmin=chart_dates.index(self.data.utc_chart_start),
67+
xmax=chart_dates.index(self.data.utc_chart_end))
68+
69+
# Configure x-axis tick marks
70+
date_labels = [str(parse_to_local(date))[:10] for date in chart_dates]
71+
plt.xticks(range(len(chart_dates)), date_labels)
72+
plt.xticks(rotation=90)
73+
74+
# Plot the ideal trendline
75+
sprint_days = (self.data.utc_sprint_end -
76+
self.data.utc_sprint_start).days
77+
plt.axline((chart_dates.index(self.data.utc_sprint_start), self.data.total_points),
78+
slope=-(self.data.total_points/(sprint_days)),
79+
**self.data.ideal_trendline_format)
80+
5681
def generate_chart(self, path):
5782
self.__prepare_chart()
5883
if not os.path.exists(path):

src/github_projects_burndown_chart/config/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from datetime import datetime
12
import json
23
import os
34
import logging
45

6+
from util.dates import parse_to_utc
7+
58
# Set up logging
69
__logger = logging.getLogger(__name__)
710
__ch = logging.StreamHandler()
@@ -39,13 +42,26 @@ def set_project(self, project_type: str, project_name: str):
3942
self.project_type = project_type
4043
self.project_name = project_name
4144

45+
def utc_sprint_start(self) -> datetime:
46+
return self.__get_date('sprint_start_date')
47+
48+
def utc_sprint_end(self) -> datetime:
49+
return self.__get_date('sprint_end_date')
50+
51+
def utc_chart_end(self) -> datetime:
52+
return self.__get_date('chart_end_date')
53+
4254
def __getitem__(self, key: str):
4355
if not hasattr(self, 'project_type'):
4456
raise AttributeError('No project has been set.')
4557
if not hasattr(self, 'project_name'):
4658
raise AttributeError('No project has been set.')
4759
return self.raw_config[self.project_type][self.project_name][key]
4860

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

5066
config = Config(__config)
5167

src/github_projects_burndown_chart/gh/api_wrapper.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import logging
2+
import os
23
import requests
3-
from requests.api import head
4+
from datetime import date
5+
import hashlib
6+
import json
7+
import tempfile
48

59
from config import config, secrets
610
from .project import Project
@@ -29,13 +33,25 @@ def get_organization_project() -> dict:
2933

3034

3135
def gh_api_query(query: str, variables: dict) -> dict:
36+
response = __get_from_cache(query, variables)
37+
if not response:
38+
response = __get_from_api(query, variables)
39+
__cache_response(query, variables, response)
40+
return response
41+
42+
43+
def prepare_payload(query, variables):
44+
return {'query': query, 'variables': variables}
45+
46+
47+
def __get_from_api(query, variables):
3248
headers = {'Authorization': 'bearer %s' % secrets['github_token']} \
3349
if 'github_token' in secrets else {}
3450

3551
response = requests.post(
3652
'https://api.github.com/graphql',
3753
headers=headers,
38-
json={'query': query, 'variables': variables}).json()
54+
json=prepare_payload(query, variables)).json()
3955

4056
# Gracefully report failures due to bad credentials
4157
if response.get('message') and response['message'] == 'Bad credentials':
@@ -53,3 +69,26 @@ def gh_api_query(query: str, variables: dict) -> dict:
5369
__logger.critical(response['errors'])
5470
exit(1)
5571
return response
72+
73+
74+
def __get_from_cache(query, variables):
75+
temp_path = __temp_path(query, variables)
76+
if os.path.exists(temp_path):
77+
with open(temp_path, 'r') as f:
78+
return json.load(f)
79+
return None
80+
81+
82+
def __cache_response(query, variables, response):
83+
temp_path = __temp_path(query, variables)
84+
with open(temp_path, 'w') as f:
85+
json.dump(response, f)
86+
87+
88+
def __temp_path(query, variables):
89+
temp_dir = tempfile.gettempdir()
90+
payload = prepare_payload(query, variables)
91+
payload.update({'today': str(date.today())})
92+
filename = f"{hashlib.sha256(json.dumps(payload).encode('utf-8')).hexdigest()}.json"
93+
temp_path = os.path.join(temp_dir, filename)
94+
return temp_path

src/github_projects_burndown_chart/gh/project.py

Lines changed: 17 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
from datetime import datetime, timedelta
2-
from typing import Dict
1+
from datetime import datetime
32
from dateutil.parser import isoparse
43

54
from config import config
6-
from util.dates import TODAY_UTC
75

86

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

23-
def points_completed_by_date(self, start_date: datetime, end_date: datetime) -> Dict[datetime, int]:
24-
"""Computes the number of points completed by date.
25-
Basically the data behind a burnup chart for the given date range.
26-
27-
Args:
28-
start_date (datetime): The start date of the chart in UTC.
29-
end_date (datetime): The end date of the chart in UTC.
30-
31-
Returns:
32-
Dict[datetime, int]: A dictionary of date and points completed.
33-
"""
34-
points_completed_by_date = {}
35-
36-
cards = [card for column in self.columns for card in column.cards]
37-
completed_cards = [card for card in cards if card.closedAt is not None]
38-
sprint_dates = [start_date + timedelta(days=x)
39-
# The +1 includes the end_date in the list
40-
for x in range(0, (end_date - start_date).days + 1)]
41-
for date in sprint_dates:
42-
# Get the issues completed before midnight on the given date.
43-
date_23_59 = date + timedelta(hours=23, minutes=59)
44-
cards_done_by_date = [card for card in completed_cards
45-
if card.closedAt <= date_23_59]
46-
points_completed_by_date[date] = sum([card.points for card
47-
in cards_done_by_date])
48-
return points_completed_by_date
49-
50-
def outstanding_points_by_date(self, start_date: datetime, end_date: datetime) -> Dict[datetime, int]:
51-
"""Computes the number of points remaining to be completed by date.
52-
Basically the data behind a burndown chart for the given date range.
53-
54-
Args:
55-
start_date (datetime): The start date of the chart in UTC.
56-
end_date (datetime): The end date of the chart in UTC.
57-
58-
Returns:
59-
Dict[datetime, int]: A dictionary of date and points remaining.
60-
"""
61-
points_completed_by_date = self.points_completed_by_date(
62-
start_date, end_date)
63-
today_23_59 = TODAY_UTC + timedelta(hours=23, minutes=59)
64-
return {
65-
date: self.total_points - points_completed_by_date[date]
66-
if date <= today_23_59 else None
67-
for date in points_completed_by_date
68-
}
21+
@property
22+
def cards(self):
23+
return [card for column in self.columns for card in column.cards]
6924

7025

7126
class Column:
@@ -84,23 +39,31 @@ def get_total_points(self):
8439
class Card:
8540
def __init__(self, card_data):
8641
card_data = card_data['content'] if card_data['content'] else card_data
87-
self.createdAt = self.__parse_createdAt(card_data)
88-
self.closedAt = self.__parse_closedAt(card_data)
42+
self.created: datetime = self.__parse_createdAt(card_data)
43+
self.assigned: datetime = self.__parse_assignedAt(card_data)
44+
self.closed: datetime = self.__parse_closedAt(card_data)
8945
self.points = self.__parse_points(card_data)
9046

91-
def __parse_createdAt(self, card_data):
47+
def __parse_assignedAt(self, card_data) -> datetime:
48+
assignedAt = None
49+
assignedDates = card_data.get('timelineItems', {}).get('nodes', [])
50+
if assignedDates:
51+
assignedAt = isoparse(assignedDates[0]['createdAt'])
52+
return assignedAt
53+
54+
def __parse_createdAt(self, card_data) -> datetime:
9255
createdAt = None
9356
if card_data.get('createdAt'):
9457
createdAt = isoparse(card_data['createdAt'])
9558
return createdAt
9659

97-
def __parse_closedAt(self, card_data):
60+
def __parse_closedAt(self, card_data) -> datetime:
9861
closedAt = None
9962
if card_data.get('closedAt'):
10063
closedAt = isoparse(card_data['closedAt'])
10164
return closedAt
10265

103-
def __parse_points(self, card_data):
66+
def __parse_points(self, card_data) -> int:
10467
card_points = 0
10568
points_label = config['settings']['points_label']
10669
if not points_label:

src/github_projects_burndown_chart/gh/queries/OrganizationProject.graphql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ query OrganizationProject($organization_name: String!, $project_number: Int!, $c
1313
content {
1414
... on Issue {
1515
title
16+
timelineItems(first: 20, itemTypes: [ASSIGNED_EVENT]) {
17+
nodes {
18+
__typename
19+
... on AssignedEvent {
20+
createdAt
21+
}
22+
}
23+
}
1624
createdAt
1725
closedAt
1826
labels(first: $labels_per_issue_count) {

src/github_projects_burndown_chart/gh/queries/RepositoryProject.graphql

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# Heavily inspired by https://github.com/radekstepan/burnchart/issues/129#issuecomment-394469442
21
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!) {
32
repository(owner: $repo_owner, name: $repo_name) {
43
project(number: $project_number) {
@@ -15,6 +14,14 @@ query RepositoryProject($repo_owner: String!, $repo_name: String!, $project_numb
1514
content {
1615
... on Issue {
1716
title
17+
timelineItems(first: 20, itemTypes: [ASSIGNED_EVENT]) {
18+
nodes {
19+
__typename
20+
... on AssignedEvent {
21+
createdAt
22+
}
23+
}
24+
}
1825
createdAt
1926
closedAt
2027
labels(first: $labels_per_issue_count) {
@@ -30,4 +37,4 @@ query RepositoryProject($repo_owner: String!, $repo_name: String!, $project_numb
3037
}
3138
}
3239
}
33-
}
40+
}

0 commit comments

Comments
 (0)