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.

Integrate #16 into new code structure.

PR #16 was opened before the latest refactor. This commit
integrates it's changes into the current code base.

Also closes #15

Joseph Hale (Oct 16, 2021, 3:33 PM -0700) d6bbd741 77230e0b

+230 -159
+143 -4
.gitignore
··· 1 + # Local config files 1 2 secrets.json 2 3 config.json 3 4 4 - **/venv** 5 - 5 + # VS Code Project Files 6 6 .vscode 7 7 8 - __pycache__ 9 8 10 - .coverage 9 + ############################################################################ 10 + # From https://github.com/github/gitignore/blob/master/Python.gitignore 11 + ############################################################################ 12 + # Byte-compiled / optimized / DLL files 13 + __pycache__/ 14 + *.py[cod] 15 + *$py.class 16 + 17 + # C extensions 18 + *.so 19 + 20 + # Distribution / packaging 21 + .Python 22 + build/ 23 + develop-eggs/ 24 + dist/ 25 + downloads/ 26 + eggs/ 27 + .eggs/ 28 + lib/ 29 + lib64/ 30 + parts/ 31 + sdist/ 32 + var/ 33 + wheels/ 34 + share/python-wheels/ 35 + *.egg-info/ 36 + .installed.cfg 37 + *.egg 38 + MANIFEST 39 + 40 + # PyInstaller 41 + # Usually these files are written by a python script from a template 42 + # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 + *.manifest 44 + *.spec 45 + 46 + # Installer logs 47 + pip-log.txt 48 + pip-delete-this-directory.txt 49 + 50 + # Unit test / coverage reports 51 + htmlcov/ 52 + .tox/ 53 + .nox/ 54 + .coverage 55 + .coverage.* 56 + .cache 57 + nosetests.xml 58 + coverage.xml 59 + *.cover 60 + *.py,cover 61 + .hypothesis/ 62 + .pytest_cache/ 63 + cover/ 64 + 65 + # Translations 66 + *.mo 67 + *.pot 68 + 69 + # Django stuff: 70 + *.log 71 + local_settings.py 72 + db.sqlite3 73 + db.sqlite3-journal 74 + 75 + # Flask stuff: 76 + instance/ 77 + .webassets-cache 78 + 79 + # Scrapy stuff: 80 + .scrapy 81 + 82 + # Sphinx documentation 83 + docs/_build/ 84 + 85 + # PyBuilder 86 + .pybuilder/ 87 + target/ 88 + 89 + # Jupyter Notebook 90 + .ipynb_checkpoints 91 + 92 + # IPython 93 + profile_default/ 94 + ipython_config.py 95 + 96 + # pyenv 97 + # For a library or package, you might want to ignore these files since the code is 98 + # intended to run in multiple environments; otherwise, check them in: 99 + # .python-version 100 + 101 + # pipenv 102 + # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 103 + # However, in case of collaboration, if having platform-specific dependencies or dependencies 104 + # having no cross-platform support, pipenv may install dependencies that don't work, or not 105 + # install all needed dependencies. 106 + #Pipfile.lock 107 + 108 + # PEP 582; used by e.g. github.com/David-OConnor/pyflow 109 + __pypackages__/ 110 + 111 + # Celery stuff 112 + celerybeat-schedule 113 + celerybeat.pid 114 + 115 + # SageMath parsed files 116 + *.sage.py 117 + 118 + # Environments 119 + .env 120 + .venv 121 + env/ 122 + venv/ 123 + ENV/ 124 + env.bak/ 125 + venv.bak/ 126 + 127 + # Spyder project settings 128 + .spyderproject 129 + .spyproject 130 + 131 + # Rope project settings 132 + .ropeproject 133 + 134 + # mkdocs documentation 135 + /site 136 + 137 + # mypy 138 + .mypy_cache/ 139 + .dmypy.json 140 + dmypy.json 141 + 142 + # Pyre type checker 143 + .pyre/ 144 + 145 + # pytype static type analyzer 146 + .pytype/ 147 + 148 + # Cython debug symbols 149 + cython_debug/
+1 -1
Makefile
··· 7 7 8 8 run: instructions 9 9 cd ./src/github_projects_burndown_chart \ 10 - && PYTHONPATH=. python main.py 10 + && PYTHONPATH=. python main.py $(project_type) 11 11 12 12 test: instructions 13 13 coverage run \
-18
src/main.py
··· 1 - from chart.burndown import BurndownChart 2 - from config import config 3 - from gh.api_wrapper import get_repo_project, get_org_project 4 - 5 - if __name__ == '__main__': 6 - repo_name = config['repo_name'] 7 - if repo_name: 8 - project = get_project( 9 - config['username'], 10 - config['repo_name'], 11 - config['project_number']) 12 - else: 13 - project = get_org_project( 14 - config['username'], 15 - config['project_number']) 16 - burndown_chart = BurndownChart(project) 17 - burndown_chart.render() 18 - print('Done')
-8
src/config/config.json.dist
··· 1 - { 2 - "username": "", 3 - "repo_name": "", 4 - "project_number": 1, 5 - "sprint_start_date": "", 6 - "sprint_end_date": "", 7 - "points_label": "" 8 - }
-117
src/gh/api_wrapper.py
··· 1 - import logging 2 - import requests 3 - from requests.api import head 4 - 5 - from config import secrets 6 - from .project import Project 7 - 8 - # Set up logging 9 - __logger = logging.getLogger(__name__) 10 - __ch = logging.StreamHandler() 11 - __ch.setFormatter( 12 - logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) 13 - __logger.addHandler(__ch) 14 - 15 - repo_project_query = """ 16 - query { 17 - repository(owner: "%(username)s", name: "%(repo_name)s") { 18 - project(number: %(project_number)d) { 19 - name 20 - columns(first: 5) { 21 - nodes { 22 - name 23 - cards(first: 50) { 24 - nodes { 25 - id 26 - note 27 - state 28 - content { 29 - ... on Issue { 30 - title 31 - createdAt 32 - closedAt 33 - labels(first: 5) { 34 - nodes { 35 - name 36 - } 37 - } 38 - } 39 - } 40 - } 41 - } 42 - } 43 - } 44 - } 45 - } 46 - } 47 - """ # Heavily inspired by https://github.com/radekstepan/burnchart/issues/129#issuecomment-394469442 48 - 49 - org_project_query = """ 50 - query { 51 - organization(login: "%(username)s") { 52 - project(number: %(project_number)d) { 53 - name 54 - columns(first: 5) { 55 - nodes { 56 - name 57 - cards(first: 50) { 58 - nodes { 59 - id 60 - note 61 - state 62 - content { 63 - ... on Issue { 64 - title 65 - createdAt 66 - closedAt 67 - labels(first: 5) { 68 - nodes { 69 - name 70 - } 71 - } 72 - } 73 - } 74 - } 75 - } 76 - } 77 - } 78 - } 79 - } 80 - } 81 - """ # Heavily inspired by https://github.com/radekstepan/burnchart/issues/129#issuecomment-394469442 82 - 83 - def get_repo_project(username: str, repo_name: str, project_number: int) -> dict: 84 - query = repo_project_query % { 85 - 'username': username, 86 - 'repo_name': repo_name, 87 - 'project_number': project_number} 88 - query_response = gh_api_query(query) 89 - project_data = query_response['data']['repository']['project'] 90 - return Project(project_data) 91 - 92 - def get_org_project(username: str, project_number: int) -> dict: 93 - query = org_project_query % { 94 - 'username': username, 95 - 'project_number': project_number} 96 - query_response = gh_api_query(query) 97 - project_data = query_response['data']['organization']['project'] 98 - return Project(project_data) 99 - 100 - def gh_api_query(query: str) -> dict: 101 - headers = {'Authorization': 'bearer %s' % secrets['github_token']} \ 102 - if 'github_token' in secrets else {} 103 - response = requests.post( 104 - 'https://api.github.com/graphql', 105 - headers=headers, 106 - json={'query': query}).json() 107 - # Gracefully report failures due to bad credentials 108 - if response.get('message') and response['message'] == 'Bad credentials': 109 - __logger.critical(response['message']) 110 - __logger.critical('Failed to extract project data from GitHub due ' 111 - 'to an invalid access token.') 112 - __logger.critical('Please set the `github_token` key in the ' 113 - '`src/secrets.json` file to a valid access token with access ' 114 - 'to the repo specified in the `src/config.json` file.') 115 - exit(1) 116 - return response 117 -
+19 -3
src/github_projects_burndown_chart/main.py
··· 1 + import argparse 2 + 1 3 from chart.burndown import BurndownChart 2 - from gh.api_wrapper import get_repository_project 4 + from gh.api_wrapper import get_organization_project, get_repository_project 3 5 from gh.project import Project 4 6 5 7 if __name__ == '__main__': 6 - project: Project = get_repository_project() 8 + # Parse the command line arguments 9 + parser = argparse.ArgumentParser( 10 + description='Generate a burndown chart for a GitHub project.') 11 + parser.add_argument("project_type", 12 + choices=['repository', 'organization'], 13 + help="The type of project to generate a burndown chart for. Can be either 'organization' or 'repository'.") 14 + args = parser.parse_args() 15 + 16 + # Pull the corresponding project from GitHub 17 + if args.project_type == 'repository': 18 + project: Project = get_repository_project() 19 + elif args.project_type == 'organization': 20 + project: Project = get_organization_project() 21 + 22 + # Generate the burndown chart 7 23 burndown_chart = BurndownChart(project) 8 24 burndown_chart.render() 9 - print('Done') 25 + print('Done')
+2 -1
src/github_projects_burndown_chart/chart/burndown.py
··· 41 41 42 42 # Set titles and labels 43 43 plt.title(f"{self.project.name}: Burndown Chart") 44 - plt.ylabel("Outstanding Points") 44 + points_label = config['settings']['points_label'] 45 + plt.ylabel(f"Outstanding {'Points' if points_label else 'Issues'}") 45 46 plt.xlabel("Date") 46 47 47 48 # Generate Plot
+7
src/github_projects_burndown_chart/config/config.json.dist
··· 7 7 "max_cards_per_column_count": 50, 8 8 "labels_per_issue_count": 5 9 9 }, 10 + "organization_project_query": { 11 + "organization_name": "REQUIRED", 12 + "project_number": -1, //REQUIRED 13 + "column_count": 5, 14 + "max_cards_per_column_count": 50, 15 + "labels_per_issue_count": 5 16 + }, 10 17 "settings": { 11 18 "sprint_start_date": "REQUIRED", 12 19 "sprint_end_date": "REQUIRED",
+16 -3
src/github_projects_burndown_chart/gh/api_wrapper.py
··· 4 4 5 5 from config import config, secrets 6 6 from .project import Project 7 - from .queries import RepositoryProject 7 + from .queries import OrganizationProject, RepositoryProject 8 8 9 9 # Set up logging 10 10 __logger = logging.getLogger(__name__) ··· 21 21 return Project(project_data) 22 22 23 23 24 + def get_organization_project() -> dict: 25 + query_response = gh_api_query( 26 + OrganizationProject, config.get('organization_project_query')) 27 + project_data = query_response['data']['organization']['project'] 28 + return Project(project_data) 29 + 30 + 24 31 def gh_api_query(query: str, variables: dict) -> dict: 25 32 headers = {'Authorization': 'bearer %s' % secrets['github_token']} \ 26 33 if 'github_token' in secrets else {} 27 - 34 + 28 35 response = requests.post( 29 36 'https://api.github.com/graphql', 30 37 headers=headers, 31 38 json={'query': query, 'variables': variables}).json() 32 - 39 + 33 40 # Gracefully report failures due to bad credentials 34 41 if response.get('message') and response['message'] == 'Bad credentials': 35 42 __logger.critical(response['message']) ··· 38 45 __logger.critical('Please set the `github_token` key in the ' 39 46 '`src/secrets.json` file to a valid access token with access ' 40 47 'to the repo specified in the `src/config.json` file.') 48 + exit(1) 49 + # Gracefully report failures due to errors 50 + elif response.get('errors'): 51 + __logger.critical('Failed to extract project data from GitHub due to ' 52 + 'an error.') 53 + __logger.critical(response['errors']) 41 54 exit(1) 42 55 return response
+8 -4
src/github_projects_burndown_chart/gh/project.py
··· 84 84 85 85 def __parse_points(self, card_data): 86 86 card_points = 0 87 - card_labels = card_data.get('labels', {"nodes": []})['nodes'] 88 - for label in card_labels: 89 - if config['settings']['points_label'] in label['name']: 90 - card_points += int(label['name'][len(config['settings']['points_label']):]) 87 + points_label = config['settings']['points_label'] 88 + if not points_label: 89 + card_points = 1 90 + else: 91 + 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):]) 91 95 return card_points
+31
src/github_projects_burndown_chart/gh/queries/OrganizationProject.graphql
··· 1 + query OrganizationProject($organization_name: String!, $project_number: Int!, $column_count: Int!, $max_cards_per_column_count: Int!, $labels_per_issue_count: Int!) { 2 + organization(login: $organization_name) { 3 + project(number: $project_number) { 4 + name 5 + columns(first: $column_count) { 6 + nodes { 7 + name 8 + cards(first: $max_cards_per_column_count) { 9 + nodes { 10 + id 11 + note 12 + state 13 + content { 14 + ... on Issue { 15 + title 16 + createdAt 17 + closedAt 18 + labels(first: $labels_per_issue_count) { 19 + nodes { 20 + name 21 + } 22 + } 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+3
src/github_projects_burndown_chart/gh/queries/__init__.py
··· 8 8 9 9 with open(os.path.join(__location__, 'RepositoryProject.graphql')) as query: 10 10 RepositoryProject = query.read() 11 + 12 + with open(os.path.join(__location__, 'OrganizationProject.graphql')) as query: 13 + OrganizationProject = query.read()