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.

Merge pull request #22 from jhale1805/dev

Improve date handling

authored by

Joseph Hale and committed by
GitHub
(Oct 21, 2021, 11:47 PM -0700) efd02e8f bcbf2212

+92 -51
+18 -16
src/github_projects_burndown_chart/chart/burndown.py
··· 3 3 4 4 from config import config 5 5 from gh.project import Project 6 + from util.dates import parse_to_local, parse_to_utc 7 + 6 8 7 9 class BurndownChart: 8 10 9 11 def __init__(self, project: Project): 10 - # Initialize important dates 11 - self.start_date = datetime.strptime( 12 - config['settings']['sprint_start_date'], 13 - '%Y-%m-%d') 14 - self.end_date = datetime.strptime( 15 - config['settings']['sprint_end_date'], 16 - '%Y-%m-%d') 17 - self.project = project 18 - 12 + self.start_date_utc: datetime = parse_to_utc( 13 + config['settings']['sprint_start_date']) 14 + self.end_date_utc: datetime = parse_to_utc( 15 + config['settings']['sprint_end_date']) 16 + 17 + self.project: Project = project 18 + 19 19 def render(self): 20 - outstanding_points_by_day = self.project.outstanding_points_by_day( 21 - self.start_date, 22 - self.end_date) 20 + outstanding_points_by_day = self.project.outstanding_points_by_date( 21 + self.start_date_utc, 22 + self.end_date_utc) 23 23 # Load date dict for priority values with x being range of how many days are in sprint 24 24 x = list(range(len(outstanding_points_by_day.keys()))) 25 25 y = list(outstanding_points_by_day.values()) ··· 27 27 # Plot point values for sprint along xaxis=range yaxis=points over time 28 28 plt.plot(x, y) 29 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))) 30 + slope=-(self.project.total_points/(len(y)-1)), 31 + color="green", 32 + linestyle=(0, (5, 5))) 33 33 34 34 # Set sprint beginning 35 35 plt.ylim(ymin=0) 36 36 plt.xlim(xmin=x[0], xmax=x[-1]) 37 37 38 38 # Replace xaxis range for date matching to range value 39 - plt.xticks(x, outstanding_points_by_day.keys()) 39 + date_labels = [str(parse_to_local(date))[:10] 40 + for date in outstanding_points_by_day.keys()] 41 + plt.xticks(x, date_labels) 40 42 plt.xticks(rotation=90) 41 43 42 44 # Set titles and labels
+53 -35
src/github_projects_burndown_chart/gh/project.py
··· 1 1 from datetime import datetime, timedelta 2 + from typing import Dict 3 + from dateutil.parser import isoparse 2 4 3 5 from config import config 6 + from util.dates import TODAY_UTC 4 7 5 8 6 9 class Project: ··· 17 20 def total_points(self): 18 21 return sum([column.get_total_points() for column in self.columns]) 19 22 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 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]) 33 48 return points_completed_by_date 34 49 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 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 + } 47 69 48 70 49 71 class Column: ··· 69 91 def __parse_createdAt(self, card_data): 70 92 createdAt = None 71 93 if card_data.get('createdAt'): 72 - createdAt = datetime.strptime( 73 - card_data['createdAt'][:10], 74 - '%Y-%m-%d') 94 + createdAt = isoparse(card_data['createdAt']) 75 95 return createdAt 76 96 77 97 def __parse_closedAt(self, card_data): 78 98 closedAt = None 79 99 if card_data.get('closedAt'): 80 - closedAt = datetime.strptime( 81 - card_data['closedAt'][:10], 82 - '%Y-%m-%d') 100 + closedAt = isoparse(card_data['closedAt']) 83 101 return closedAt 84 102 85 103 def __parse_points(self, card_data): ··· 89 107 card_points = 1 90 108 else: 91 109 card_labels = card_data.get('labels', {"nodes": []})['nodes'] 92 - for label in card_labels: 93 - if points_label in label['name']: 94 - card_points += int(label['name'][len(points_label):]) 95 - return card_points 110 + card_points = sum([int(label['name'][len(points_label):]) 111 + for label in card_labels 112 + if points_label in label['name']]) 113 + return card_points
src/github_projects_burndown_chart/util/__init__.py
+21
src/github_projects_burndown_chart/util/dates.py
··· 1 + from datetime import datetime, timedelta, timezone 2 + from dateutil import parser 3 + 4 + UTC_OFFSET: timedelta = datetime.utcnow() - datetime.now() 5 + """Local time + UTC_OFFSET = UTC Time""" 6 + 7 + def parse_to_utc(date_string: str) -> datetime: 8 + """ 9 + Parse a date string to UTC time. 10 + """ 11 + raw_datetime = parser.parse(date_string) + UTC_OFFSET 12 + datetime_utc = raw_datetime.replace(tzinfo=timezone.utc) 13 + return datetime_utc 14 + 15 + def parse_to_local(datetime_utc: datetime) -> datetime: 16 + """ 17 + Parse a datetime object to local time. 18 + """ 19 + return datetime_utc.astimezone() 20 + 21 + TODAY_UTC: datetime = parse_to_utc(datetime.today().strftime('%Y-%m-%d'))