From fd5e5725dcec87b3ce93e9b28c205f45339aff11 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 16 Oct 2021 14:16:09 -0700 Subject: [PATCH] Refactor -> use parameterized GraphQL query To make a cleaner separation between graphql and python code, this commit extracts the graphql query text from the api_wrapper module. Folders were restructured to facilitate the future addition of automated unit tests. Lastly, a Makefile was added to make it easier to build, test, and run the burndown chart generator. --- .gitignore | 4 +- Makefile | 18 +++++ README.md | 20 ++--- requirements.txt | Bin 698 -> 602 bytes src/config/config.json.dist | 8 -- src/gh/api_wrapper.py | 75 ------------------ .../__init__.py | 0 .../chart/__init__.py | 0 .../chart/burndown.py | 4 +- .../config/__init__.py | 0 .../config/config.json.dist | 15 ++++ .../config/secrets.json.dist | 0 .../gh/__init__.py | 0 .../gh/api_wrapper.py | 42 ++++++++++ .../gh/project.py | 4 +- .../gh/queries/RepositoryProject.graphql | 33 ++++++++ .../gh/queries/__init__.py | 10 +++ src/github_projects_burndown_chart/main.py | 9 +++ src/main.py | 12 --- test/__init__.py | 7 ++ test/fixtures/__init__.py | 0 .../__init__.py | 0 .../chart/__init__.py | 0 .../gh/__init__.py | 0 .../gh/test_api_wrapper.py | 0 .../gh/test_project.py | 6 ++ 26 files changed, 158 insertions(+), 109 deletions(-) create mode 100644 Makefile delete mode 100644 src/config/config.json.dist delete mode 100644 src/gh/api_wrapper.py rename src/{ => github_projects_burndown_chart}/__init__.py (100%) rename src/{ => github_projects_burndown_chart}/chart/__init__.py (100%) rename src/{ => github_projects_burndown_chart}/chart/burndown.py (93%) rename src/{ => github_projects_burndown_chart}/config/__init__.py (100%) create mode 100644 src/github_projects_burndown_chart/config/config.json.dist rename src/{ => github_projects_burndown_chart}/config/secrets.json.dist (100%) rename src/{ => github_projects_burndown_chart}/gh/__init__.py (100%) create mode 100644 src/github_projects_burndown_chart/gh/api_wrapper.py rename src/{ => github_projects_burndown_chart}/gh/project.py (95%) create mode 100644 src/github_projects_burndown_chart/gh/queries/RepositoryProject.graphql create mode 100644 src/github_projects_burndown_chart/gh/queries/__init__.py create mode 100644 src/github_projects_burndown_chart/main.py delete mode 100644 src/main.py create mode 100644 test/__init__.py create mode 100644 test/fixtures/__init__.py create mode 100644 test/github_projects_burndown_chart/__init__.py create mode 100644 test/github_projects_burndown_chart/chart/__init__.py create mode 100644 test/github_projects_burndown_chart/gh/__init__.py create mode 100644 test/github_projects_burndown_chart/gh/test_api_wrapper.py create mode 100644 test/github_projects_burndown_chart/gh/test_project.py diff --git a/.gitignore b/.gitignore index 72f989a..105f35a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ config.json .vscode -__pycache__ \ No newline at end of file +__pycache__ + +.coverage \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ad7fcca --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +instructions: + @echo "[NOTE]: If you see any errors, make sure your virtual environment is active!" + +build: instructions + pip install -r requirements.txt + + +run: instructions + cd ./src/github_projects_burndown_chart \ + && PYTHONPATH=. python main.py + +test: instructions + coverage run \ + --source=src/github_projects_burndown_chart \ + --branch \ + -m unittest discover -v + +.PHONY: build run test \ No newline at end of file diff --git a/README.md b/README.md index 4cae5c3..a02fbe1 100644 --- a/README.md +++ b/README.md @@ -57,30 +57,32 @@ pip install -r requirements.txt ``` ## Usage +### Configuration 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. +2. Make a copy of `src/github_projects_burndown_chart/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. +4. Make a copy of `src/github_projects_burndown_chart/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. + - `repository_project_query.repo_owner`: The username of the owner of the repo. - For example, `jhale1805` - - `repo_name`: The name of the repo. + - `repository_project_query.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. + - `repository_project_query.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`. + - `settings.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`. + - `settings.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. + - `settings.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. +### Generating the Chart +1. Run `make run` 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 diff --git a/requirements.txt b/requirements.txt index 400574ab86105aa5b18e138275938df9aa7bd463..7aa3907ce558892118944fd1c263036907f05a9c 100644 GIT binary patch delta 147 zcmdnRdW%Kn|Gz|rQic+Se1-ysRE7cu3kF*TTLwc0JqA+-JqB|IUIs3PiA)L7`3z+Y zsSHI7i45rssbDo`40;R(40;Sk6Qi3a2Qj+HCo|+Tq%fp16f=}CR5Ij1l^HSUF<5|< fPVQx_oLt2uCl0bKmmvpi2*?0Kh%u8VGpPdr`*9sw delta 285 zcmcb`vWu1J|G$YU2@{vq)GIJ3Feo!9FoZA^F_bc-GAJ;lFr+dRFr+f%fmwMB$qbbY zIt&U7i3~XmISdL6`3xlt84RfmMGVDY(IT*}5{4p%M20+uVunnx-V&@Pf#ixAcp11D k@)$}Pav2IH>oQtS?qe(%Fn565TftyEnUhI)vIUb80H~HY^Z)<= diff --git a/src/config/config.json.dist b/src/config/config.json.dist deleted file mode 100644 index ae8c190..0000000 --- a/src/config/config.json.dist +++ /dev/null @@ -1,8 +0,0 @@ -{ - "repo_owner": "", - "repo_name": "", - "project_number": 1, - "sprint_start_date": "", - "sprint_end_date": "", - "points_label": "" -} \ No newline at end of file diff --git a/src/gh/api_wrapper.py b/src/gh/api_wrapper.py deleted file mode 100644 index 316e807..0000000 --- a/src/gh/api_wrapper.py +++ /dev/null @@ -1,75 +0,0 @@ -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 - \ No newline at end of file diff --git a/src/__init__.py b/src/github_projects_burndown_chart/__init__.py similarity index 100% rename from src/__init__.py rename to src/github_projects_burndown_chart/__init__.py diff --git a/src/chart/__init__.py b/src/github_projects_burndown_chart/chart/__init__.py similarity index 100% rename from src/chart/__init__.py rename to src/github_projects_burndown_chart/chart/__init__.py diff --git a/src/chart/burndown.py b/src/github_projects_burndown_chart/chart/burndown.py similarity index 93% rename from src/chart/burndown.py rename to src/github_projects_burndown_chart/chart/burndown.py index 951f55c..321d7b4 100644 --- a/src/chart/burndown.py +++ b/src/github_projects_burndown_chart/chart/burndown.py @@ -9,10 +9,10 @@ class BurndownChart: def __init__(self, project: Project): # Initialize important dates self.start_date = datetime.strptime( - config['sprint_start_date'], + config['settings']['sprint_start_date'], '%Y-%m-%d') self.end_date = datetime.strptime( - config['sprint_end_date'], + config['settings']['sprint_end_date'], '%Y-%m-%d') self.project = project diff --git a/src/config/__init__.py b/src/github_projects_burndown_chart/config/__init__.py similarity index 100% rename from src/config/__init__.py rename to src/github_projects_burndown_chart/config/__init__.py diff --git a/src/github_projects_burndown_chart/config/config.json.dist b/src/github_projects_burndown_chart/config/config.json.dist new file mode 100644 index 0000000..2431e84 --- /dev/null +++ b/src/github_projects_burndown_chart/config/config.json.dist @@ -0,0 +1,15 @@ +{ + "repository_project_query": { + "repo_owner": "REQUIRED", + "repo_name": "REQUIRED", + "project_number": -1, //REQUIRED + "column_count": 5, + "max_cards_per_column_count": 50, + "labels_per_issue_count": 5 + }, + "settings": { + "sprint_start_date": "REQUIRED", + "sprint_end_date": "REQUIRED", + "points_label": "REQUIRED" + } +} \ No newline at end of file diff --git a/src/config/secrets.json.dist b/src/github_projects_burndown_chart/config/secrets.json.dist similarity index 100% rename from src/config/secrets.json.dist rename to src/github_projects_burndown_chart/config/secrets.json.dist diff --git a/src/gh/__init__.py b/src/github_projects_burndown_chart/gh/__init__.py similarity index 100% rename from src/gh/__init__.py rename to src/github_projects_burndown_chart/gh/__init__.py diff --git a/src/github_projects_burndown_chart/gh/api_wrapper.py b/src/github_projects_burndown_chart/gh/api_wrapper.py new file mode 100644 index 0000000..a678fa3 --- /dev/null +++ b/src/github_projects_burndown_chart/gh/api_wrapper.py @@ -0,0 +1,42 @@ +import logging +import requests +from requests.api import head + +from config import config, secrets +from .project import Project +from .queries import RepositoryProject + +# 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) + + +def get_repository_project() -> dict: + query_response = gh_api_query( + RepositoryProject, config.get('repository_project_query')) + project_data = query_response['data']['repository']['project'] + return Project(project_data) + + +def gh_api_query(query: str, variables: dict) -> 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, 'variables': variables}).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 diff --git a/src/gh/project.py b/src/github_projects_burndown_chart/gh/project.py similarity index 95% rename from src/gh/project.py rename to src/github_projects_burndown_chart/gh/project.py index 9464f80..1ef7bb6 100644 --- a/src/gh/project.py +++ b/src/github_projects_burndown_chart/gh/project.py @@ -86,6 +86,6 @@ class Card: 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']):]) + if config['settings']['points_label'] in label['name']: + card_points += int(label['name'][len(config['settings']['points_label']):]) return card_points \ No newline at end of file diff --git a/src/github_projects_burndown_chart/gh/queries/RepositoryProject.graphql b/src/github_projects_burndown_chart/gh/queries/RepositoryProject.graphql new file mode 100644 index 0000000..93a16b0 --- /dev/null +++ b/src/github_projects_burndown_chart/gh/queries/RepositoryProject.graphql @@ -0,0 +1,33 @@ +# 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) { + name + id + columns(first: $column_count) { + nodes { + name + cards(first: $max_cards_per_column_count) { + nodes { + id + note + state + content { + ... on Issue { + title + createdAt + closedAt + labels(first: $labels_per_issue_count) { + nodes { + name + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/github_projects_burndown_chart/gh/queries/__init__.py b/src/github_projects_burndown_chart/gh/queries/__init__.py new file mode 100644 index 0000000..e7c31e6 --- /dev/null +++ b/src/github_projects_burndown_chart/gh/queries/__init__.py @@ -0,0 +1,10 @@ +import os + +# File I/O inspired by https://stackoverflow.com/a/4060259/14765128 +__location__ = os.path.realpath( + os.path.join( + os.getcwd(), + os.path.dirname(__file__))) + +with open(os.path.join(__location__, 'RepositoryProject.graphql')) as query: + RepositoryProject = query.read() diff --git a/src/github_projects_burndown_chart/main.py b/src/github_projects_burndown_chart/main.py new file mode 100644 index 0000000..de219c5 --- /dev/null +++ b/src/github_projects_burndown_chart/main.py @@ -0,0 +1,9 @@ +from chart.burndown import BurndownChart +from gh.api_wrapper import get_repository_project +from gh.project import Project + +if __name__ == '__main__': + project: Project = get_repository_project() + burndown_chart = BurndownChart(project) + burndown_chart.render() + print('Done') \ No newline at end of file diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 032adfd..0000000 --- a/src/main.py +++ /dev/null @@ -1,12 +0,0 @@ -from chart.burndown import BurndownChart -from config import config -from gh.api_wrapper import get_project - -if __name__ == '__main__': - project = get_project( - config['repo_owner'], - config['repo_name'], - config['project_number']) - burndown_chart = BurndownChart(project) - burndown_chart.render() - print('Done') \ No newline at end of file diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..c31499c --- /dev/null +++ b/test/__init__.py @@ -0,0 +1,7 @@ +import os +import sys + +# DON'T CHANGE THIS LINE - IT'S WHAT ALLOWS THE TESTS TO IMPORT THE MODULE CODE +# Based on: https://docs.python-guide.org/writing/structure/#test-suite +sys.path.insert(0, os.path.abspath( + os.path.join(os.path.dirname(__file__), '../src'))) diff --git a/test/fixtures/__init__.py b/test/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/github_projects_burndown_chart/__init__.py b/test/github_projects_burndown_chart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/github_projects_burndown_chart/chart/__init__.py b/test/github_projects_burndown_chart/chart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/github_projects_burndown_chart/gh/__init__.py b/test/github_projects_burndown_chart/gh/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/github_projects_burndown_chart/gh/test_api_wrapper.py b/test/github_projects_burndown_chart/gh/test_api_wrapper.py new file mode 100644 index 0000000..e69de29 diff --git a/test/github_projects_burndown_chart/gh/test_project.py b/test/github_projects_burndown_chart/gh/test_project.py new file mode 100644 index 0000000..215fdb5 --- /dev/null +++ b/test/github_projects_burndown_chart/gh/test_project.py @@ -0,0 +1,6 @@ +import unittest + +class TestProject(unittest.TestCase): + + def test_dummy(self): + self.assertTrue(True) \ No newline at end of file -- 2.51.2