diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index df2142a..27892ae 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -8,6 +8,8 @@ env: FRESHDESK_KEY: ${{ secrets.FRESHDESK_KEY}} FRESHDESK_HOST: ${{ secrets.FRESHDESK_HOST}} TEST_EMAIL: ${{ secrets.TEST_EMAIL}} + JIRA_HOST_1: ${{secrets.JIRA_HOST_1}} + JIRA_KEY_1: ${{secrets.JIRA_KEY_1}} # Controls when the workflow will run on: # Triggers the workflow on push or pull request events but only for the main branch diff --git a/serviceHelpers/__init__.py b/serviceHelpers/__init__.py index 6a2b67e..2bb0ba3 100644 --- a/serviceHelpers/__init__.py +++ b/serviceHelpers/__init__.py @@ -1,4 +1,5 @@ from serviceHelpers.models import ZendeskOrg, ZendeskTicket, ZendeskUser, ZendeskWorklog from serviceHelpers.models import hueBulb +from serviceHelpers.models import JiraTicket, JiraDetails print("Init successful!") diff --git a/serviceHelpers/jira.py b/serviceHelpers/jira.py index 1c8f278..da00953 100644 --- a/serviceHelpers/jira.py +++ b/serviceHelpers/jira.py @@ -1,170 +1,86 @@ -from datetime import datetime import json import logging import urllib.parse -from time import strptime -from pytest import param import requests +from serviceHelpers.models.JiraDetails import JiraDetails +from serviceHelpers.models.JiraTicket import JiraTicket +from serviceHelpers.models.JiraWorklog import JiraWorklog + LO = logging.getLogger("jira") TIMESTAMP_FORMAT = r"%Y-%m-%dT%H:%M:%S.%f%z" class Jira: - """ Represents a single Jira instance, accepting necessary methods to use the API. + """Represents a single Jira instance, accepting necessary methods to use the API. Exposes methods for fetching tickets. """ - def __init__(self, config) -> None: - # config = jiraDetails() + + def __init__(self, config: JiraDetails) -> None: + + config: JiraDetails self.valid = config.valid self.host = config.host self.name = config.name self.token = config.key + self.headers = { + "Content-Type": "application/json", + "Authorization": f"Basic {self.token}", + } + self.logger = LO def fetch_jira_tickets(self, jql) -> dict: "takes a JQL string, encodes it, send its to Jira, and returns a dict of tickets, with the ticket ID (PRJ-123) as the dict key" - headers = { - "Content-Type": "application/json", - "Authorization": "Basic %s" % self.token, - } + jql = urllib.parse.quote(jql) url = f"https://{self.host}/rest/api/2/search?jql={jql}&fields=key,summary,description,status,priority,assignee,created,updated" - try: - r = requests.get(url, headers=headers) - except Exception as ex: - LO.error("Request failed for unknown reason %s",ex) - - if r.status_code != 200: - print( - "ERROR: %s could not fetch Jira tickets\n%s" - % (r.status_code, r.content) - ) - return [] - loaded_tickets = json.loads(r.content) + + retrieved_results = _request_and_validate(url, self.headers) incoming_tickets = {} - for ticket in loaded_tickets["issues"]: + for ticket in retrieved_results["issues"]: new_ticket = JiraTicket().from_dict(ticket) - incoming_tickets[ticket["key"]] = new_ticket # print(json.dumps(ticket,indent=2)) return incoming_tickets - -class JiraTicket: - "represents a single Jira ticket" - - def __init__( - self, - key="", - summary="", - assignee_id="", - assignee_name="", - status="", - priority="", - description="", - created=datetime.min, - updated=datetime.min, - ) -> None: - self.key = key - self.summary = summary - self.description = description - self.assignee_id = assignee_id - self.assignee_name = assignee_name - self.status = status - self.priority = priority - self.created = created - self.updated = updated - - - def from_dict(self, new_dict: dict): - """Takes the dictionary returned from the API and applies the contents to the matching parameters.""" - fields = new_dict["fields"] if "fields" in new_dict else {} - assignee_dict = fields["assignee"] if "assignee" in fields else {} - assignee_dict = {} if assignee_dict is None else assignee_dict - priority_dict = fields["priority"] if "priority" in fields else {} - - self.key = new_dict["key"] if "key" in new_dict else self.key - self.summary = fields["summary"] if "summary" in fields else self.summary - self.description = ( - fields["description"] if "description" in fields else self.description - ) - self.assignee_id = ( - assignee_dict["key"] if "key" in assignee_dict else self.assignee_id - ) - self.assignee_id = ( - assignee_dict["accountId"] - if "accountId" in assignee_dict - else self.assignee_id + def fetch_worklogs_for_jira_ticket(self, ticket_key: str) -> list: + "takes a ticket key and returns the worklogs for it" + + url = f"https://{self.host}/rest/api/2/issue/{ticket_key}/worklog" + results = _request_and_validate(url, self.headers) + worklogs = [] + if "worklogs" in results: + for worklog in results["worklogs"]: + parsed_worklog = JiraWorklog().from_json(worklog) + if parsed_worklog.is_valid: + worklogs.append(parsed_worklog) + + return worklogs + + +def _request_and_validate(url, headers, body=None) -> dict: + "internal method to request and return results from Jira" + + try: + result = requests.get(url=url, headers=headers, data=body) + except (ConnectionError) as e: + LO.error("Couldn't connect to Jira %s - %s", url, e) + return {} + if result.status_code != 200: + LO.error( + "Got an invalid response on the endpoint %s: %s - %s ", + url, + result.status_code, + result.content, ) - self.assignee_name = ( - assignee_dict["displayName"] - if "displayName" in assignee_dict - else self.assignee_name - ) - self.priority = ( - priority_dict["name"] if "name" in priority_dict else self.priority - ) - - status_dict = fields["status"] if "status" in fields else {} - self.status = status_dict["name"] if "name" in status_dict else self.status - - self.created = ( - datetime.strptime(fields["created"], TIMESTAMP_FORMAT) - if "created" in fields - else self.created - ) - - self.updated = ( - datetime.strptime(fields["updated"], TIMESTAMP_FORMAT) - if "updated" in fields - else self.updated - ) - - return self - - -class JiraDetails: - """represents the settings for a jira object""" - - def __init__(self) -> None: - self.host = "" - self.name = "" - self.key = "" - self.default_assignee = "" - - self._lo = logging.getLogger("jiraDetails") - - - def from_dict(self, src: dict) : - """Takes a dictionary and tries to map appropriate details to the class parameters.""" - self.name = src["instanceName"] if "instanceName" in src else "" - self.host = src["instanceHost"] if "instanceHost" in src else "" - self.key = src["bearerToken"] if "bearerToken" in src else "" - self.default_assignee = ( - src["instanceUserID"] if "instanceUserID" in src else "" - ) - - - return self - - @param - def valid(self): - """self checking of the parameters.""" - valid = True - if self.name == "": - LO.warning("jira obj name is blank") - valid = False - if self.host == "": - LO.warning("jira object host is blank") - valid = False - if self.default_assignee == "": - LO.warning("jira object primary_user_id is blank") - if self.key == "": - LO.warning("key missing from jira settings") - valid = False - - return valid + return {} + try: + parsed_content = json.loads(result.content) + except json.JSONDecodeError as e: + LO.error("Couldn't parse JSON from Jira - %s", e) + return {} + return parsed_content diff --git a/serviceHelpers/models/JiraDetails.py b/serviceHelpers/models/JiraDetails.py new file mode 100644 index 0000000..d807883 --- /dev/null +++ b/serviceHelpers/models/JiraDetails.py @@ -0,0 +1,40 @@ +import logging +from pytest import param + +class JiraDetails: + """represents the settings for a jira object""" + + def __init__(self) -> None: + self.host = "" + self.name = "" + self.key = "" + self.default_assignee = "" + + self.logger = logging.getLogger("jiraDetails") + + def from_dict(self, src: dict): + """Takes a dictionary and tries to map appropriate details to the class parameters.""" + self.name = src["instanceName"] if "instanceName" in src else "" + self.host = src["instanceHost"] if "instanceHost" in src else "" + self.key = src["bearerToken"] if "bearerToken" in src else "" + self.default_assignee = src["instanceUserID"] if "instanceUserID" in src else "" + + return self + + @param + def valid(self): + """self checking of the parameters.""" + valid = True + if self.name == "": + self.logger.warning("jira obj name is blank") + valid = False + if self.host == "": + self.logger.warning("jira object host is blank") + valid = False + if self.default_assignee == "": + self.logger.warning("jira object primary_user_id is blank") + if self.key == "": + self.logger.warning("key missing from jira settings") + valid = False + + return valid diff --git a/serviceHelpers/models/JiraTicket.py b/serviceHelpers/models/JiraTicket.py new file mode 100644 index 0000000..15cd54b --- /dev/null +++ b/serviceHelpers/models/JiraTicket.py @@ -0,0 +1,82 @@ +from datetime import datetime + +TIMESTAMP_FORMAT = r"%Y-%m-%dT%H:%M:%S.%f%z" + + +class JiraTicket: + "represents a single Jira ticket" + + def __init__( + self, + key="", + summary="", + assignee_id="", + assignee_name="", + assignee_email="", + status="", + priority="", + description="", + created=datetime.min, + updated=datetime.min, + ) -> None: + self.key = key + self.summary = summary + self.description = description + self.assignee_id = assignee_id + self.assignee_name = assignee_name + self.assignee_email = assignee_email + self.status = status + self.priority = priority + self.created = created + self.updated = updated + + def from_dict(self, new_dict: dict): + """Takes the dictionary returned from the API and applies the contents to the matching parameters.""" + fields = new_dict["fields"] if "fields" in new_dict else {} + assignee_dict = fields["assignee"] if "assignee" in fields else {} + assignee_dict = {} if assignee_dict is None else assignee_dict + priority_dict = fields["priority"] if "priority" in fields else {} + + self.key = new_dict["key"] if "key" in new_dict else self.key + self.summary = fields["summary"] if "summary" in fields else self.summary + self.description = ( + fields["description"] if "description" in fields else self.description + ) + self.assignee_id = ( + assignee_dict["key"] if "key" in assignee_dict else self.assignee_id + ) + self.assignee_id = ( + assignee_dict["accountId"] + if "accountId" in assignee_dict + else self.assignee_id + ) + self.assignee_name = ( + assignee_dict["displayName"] + if "displayName" in assignee_dict + else self.assignee_name + ) + self.assignee_email = ( + assignee_dict["emailAddress"] + if "emailAddress" in assignee_dict + else self.assignee_email + ) + self.priority = ( + priority_dict["name"] if "name" in priority_dict else self.priority + ) + + status_dict = fields["status"] if "status" in fields else {} + self.status = status_dict["name"] if "name" in status_dict else self.status + + self.created = ( + datetime.strptime(fields["created"], TIMESTAMP_FORMAT) + if "created" in fields + else self.created + ) + + self.updated = ( + datetime.strptime(fields["updated"], TIMESTAMP_FORMAT) + if "updated" in fields + else self.updated + ) + + return self diff --git a/serviceHelpers/models/JiraWorklog.py b/serviceHelpers/models/JiraWorklog.py new file mode 100644 index 0000000..4772ab2 --- /dev/null +++ b/serviceHelpers/models/JiraWorklog.py @@ -0,0 +1,60 @@ +from datetime import datetime +import logging + +TIMESTAMP_FORMAT = r"%Y-%m-%dT%H:%M:%S.%f%z" + + +class JiraWorklog: + "represents a single instance of time tracking on a Jira ticket" + + def __init__(self) -> None: + self.created = datetime.min + self.duration_seconds = 0 + self.author_key = "" + self.author_email = "" + self.logger = logging.getLogger("JiraWorklog") + + def from_json(self, json_dict: dict): + "takes the JSON returned from the rest API and extracts appropriate properties" + print(json_dict) + + try: + self.created = datetime.strptime(json_dict.get("created"), TIMESTAMP_FORMAT) + except (ValueError, Exception) as err: + self.logger.error( + "Couldn't parse %s as timestamp using format %s becase %s", + json_dict.get("created"), + TIMESTAMP_FORMAT, + err, + ) + try: + self.duration_seconds = int(json_dict.get("timeSpentSeconds")) + except (ValueError, Exception) as err: + self.logger.error( + "Couldn't parse %s as an int for duration, because %s", + json_dict.get("timeSpentSeconds"), + err, + ) + + author_deets = json_dict.get("author") if "author" in json_dict else {} + + self.author_email = ( + author_deets.get("emailAddress") + if "emailAddress" in author_deets + else self.author_email + ) + self.author_key = ( + author_deets.get("key") if "key" in author_deets else self.author_key + ) + + return self + + @property + def is_valid(self): + "whether or not the necessary parameters are populated" + valid = 0 + valid += 1 if self.author_key != "" else 0 + valid += 1 if self.author_email != "" else 0 + valid += 1 if self.created != datetime.min else 0 + + return valid == 3 diff --git a/setup.py b/setup.py index b02978e..ca280f5 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name="hex-helpers", - version="2.1.1", + version="2.2.0", description="A series of light helpers for `freshdesk`,`gmail`,`habitica`,`hue lights`,`jira`,`slack`,`trello`", long_description=README, long_description_content_type="text/markdown", diff --git a/tests/test_jira.py b/tests/test_jira.py index 208d14c..8d998ac 100644 --- a/tests/test_jira.py +++ b/tests/test_jira.py @@ -1,4 +1,3 @@ - import sys from datetime import datetime @@ -9,62 +8,89 @@ import pytest from pytest import LogCaptureFixture -from serviceHelpers.jira import Jira, JiraDetails, JiraTicket, TIMESTAMP_FORMAT +import serviceHelpers.jira +from serviceHelpers.jira import Jira, TIMESTAMP_FORMAT, JiraDetails, JiraTicket + +import os LO = logging.getLogger("jira_tests") + +def test_keys(): + "Checks whether environment variables are properly set" + host = os.environ.get("JIRA_HOST_1") + assert host is not None + + api_key = os.environ.get("JIRA_KEY_1") + assert api_key is not None + + +def get_details() -> JiraDetails: + "generate the `JiraDetails` object used for endpoint testing" + deets = JiraDetails() + deets.host = os.environ.get("JIRA_HOST_1") + deets.key = os.environ.get("JIRA_KEY_1") + deets.name = "test_environ" + return deets + + @pytest.mark.skip("Not implemented yet") -def test_details() -> JiraDetails : +def test_details() -> JiraDetails: """Check that the jira details class loads things from the json file""" failures = 0 try: - file = open("tests/jira_settings.json",encoding="utf8") + file = open("tests/jira_settings.json", encoding="utf8") loaded_details = json.load(file) loaded_details = loaded_details["test_details"] file.close() except Exception as ex: - LO.error("Couldn't open the jira_settings.json file, %s",ex) + LO.error("Couldn't open the jira_settings.json file, %s", ex) failures += 1 - + details_object = JiraDetails().from_dict(loaded_details) try: details_object.host = loaded_details["host"] - failures = failures + 1 if details_object.host != loaded_details["host"] else failures + failures = ( + failures + 1 if details_object.host != loaded_details["host"] else failures + ) except Exception as ex: failures += 1 LO.error(ex) try: details_object.key = loaded_details["key"] - failures = failures + 1 if details_object.key != loaded_details["key"] else failures + failures = ( + failures + 1 if details_object.key != loaded_details["key"] else failures + ) except Exception as ex: failures += 1 LO.error(ex) try: details_object.name = loaded_details["name"] - failures = failures + 1 if details_object.name != loaded_details["name"] else failures + failures = ( + failures + 1 if details_object.name != loaded_details["name"] else failures + ) except Exception as ex: failures += 1 LO.error(ex) - if failures != 0 and details_object.valid: - failures +=1 + failures += 1 LO.warning("Jira details object is not reporting valid correctly") elif failures == 0 and not details_object.valid: - failures +=1 + failures += 1 LO.warning("Jira details object is not reporting valid correctly") - + assert failures == 0 return details_object - -@pytest.mark.skip("Not implemented yet") + + def test_jira_init() -> Jira: """using the output of test_details, check we have access.""" failures = 0 - details_obj = test_details() + details_obj = get_details() test_instance = Jira(details_obj) @@ -84,70 +110,42 @@ def test_jira_init() -> Jira: assert failures == 0 return test_instance -@pytest.mark.skip("Not implemented yet") -def test_fetch(caplog:LogCaptureFixture) -> JiraDetails: + +def test_fetch(caplog: LogCaptureFixture) -> JiraTicket: """Exceutes a JQL string (expects a single closed ticket return)""" failures = 0 - try: - file = open("tests/jira_settings.json",encoding="utf8") - loaded_details = json.load(file) - loaded_details = loaded_details["test_fetch"] - file.close() - except Exception as ex: - assert False jira_instance = test_jira_init() - test_tickets = jira_instance.fetch_jira_tickets(jql=loaded_details["jql"]) - - assert len(test_tickets) > 0 - ticket_key = list(test_tickets.keys())[0] - test_ticket = test_tickets[ticket_key] + tickets = jira_instance.fetch_jira_tickets("key = 'GSDSE-1'") for record in caplog.records: assert record.levelname >= "ERROR" assert failures == 0 - return test_ticket + assert len(tickets) == 1 + assert isinstance(tickets["GSDSE-1"], JiraTicket) + return tickets["GSDSE-1"] -@pytest.mark.skip("Not implemented yet") -def test_ticket_content(caplog:LogCaptureFixture): + +def test_ticket_content(caplog: LogCaptureFixture): """Checks a supplied ticket against the test config""" failures = 0 - try: - file = open("tests/jira_settings.json",encoding="utf8") - loaded_details = json.load(file) - loaded_details = loaded_details["test_ticket_content"] - file.close() - except Exception as ex: - LO.error("Couldn't open the jira_settings.json file, %s",ex) - failures += 1 ticket = test_fetch(caplog) - assert isinstance(ticket,JiraTicket) + assert isinstance(ticket, JiraTicket) - assert ticket.assignee_id == loaded_details["expected_assignee_id"] - assert ticket.assignee_name == loaded_details["expected_assignee_name"] - try: - match = ticket.created == datetime.strptime(loaded_details["expected_created_timestamp"],TIMESTAMP_FORMAT) - except (ValueError) as ex: - LO.error("Couldn't parse the created timestamp of the test string - embarassing. %s",ex) - assert False - - assert match - - try: - match = ticket.updated == datetime.strptime(loaded_details["expected_updated_timestamp"],TIMESTAMP_FORMAT) - except ValueError as ex: - LO.error("Couldn't parse the updated timestamp of the test string - embarassing") - assert False - - assert match - - if loaded_details["expected_description"] == "": - assert ticket.description is None - else: - assert ticket.description == loaded_details["expected_description"] - assert ticket.priority == loaded_details["expected_priority"] - assert ticket.key == loaded_details["expected_key"] - assert ticket.status == loaded_details["expected_status"] - assert ticket.summary == loaded_details["expected_summary"] - - + assert ticket.description != "" + assert ticket.status != "" + assert ticket.priority != "" + assert ticket.assignee_id != "" + assert ticket.assignee_name != "" + assert ticket.assignee_email != "" + assert ticket.key != "" + assert ticket.summary != "" + + +def test_fetch_worklogs(): + "Checks the response when fetching worklogs from a known ticket" + details = get_details() + instance = Jira(details) + + results = instance.fetch_worklogs_for_jira_ticket("GSDSE-51") + assert len(results) > 0