def readDepList(filter_id, user, passwd): """ Reads a DEP list from a JIRA CM ticket and returns a sorted list to be iterated through. """ global jira_url # Establish connection to JIRA jac = JIRA(server=jira_url, basic_auth=(user, passwd)) # Set the filter from the CLI argument filter_query = jac.filter(filter_id) # Perform the search and return the result list results = jac.search_issues(filter_query.jql) depList = [] for result in results: try: # Looks at the "Deploy Time" field to filter deploys from the list that # do not happen at 9PM Eastern on the current day dep_date = datetime.strptime(result.fields.customfield_10305, "%Y-%m-%dT%H:%M:%S.%f%z") if dep_date.date() == date.today() and dep_date.hour == 21: # Append if the field is not blank and contains today's date and # 9PM ET time depList.append(jira_url + 'browse/' + result.key + ' - ' + result.fields.summary) except AttributeError: # Append if the field does not exist depList.append(jira_url + 'browse/' + result.key + ' - ' + result.fields.summary) # Return a sorted list (DBADMIN first, then DEP) return sorted(depList)
class JiraBoard(object): def __init__(self, config: ConfigParser, userpath: str = None, testMode: bool = False): """Initialize JiraBoard with all relevant QA Jira stories. :param config: Jira configuration settings :param testMode: if True, load test data from a JSON file instead of making requests to the API """ try: self.host = config.get('jira', 'url') self.username = config.get('jira', 'username') self.token = config.get('jira', 'token') self.project_key = config.get('jira', 'project_key') except ParsingError as e: raise JiraBoardException # Tester credentials import if userpath is not None: upath = (os.path.relpath(os.path.join('config', userpath))) else: upath = (os.path.relpath(os.path.join('config', 'users.ini'))) self.testers = [ t['jira_displayname'] for t in get_configs(['jira_displayname'], upath).values() ] self.testMode = testMode self.options = {'server': self.host} self.jira = JIRA(self.options, auth=(self.username, self.token)) self.board = self.get_board(self.project_key) self.current_sprint = self.get_current_sprint(self.board.id) self.raw_issues = [] self.stories = [] def add_new_filter(self, filter_name: str, new_query: str) -> Filter: """Add a new JQL filter to the Jira project. :param filter_name: name for the filter :param new_query: filter criteria in the form of a JQL string. :return: """ try: result = self.jira.create_filter(filter_name, jql=new_query) except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira stories.') else: return result def update_filter(self, filter_id: int, new_query: str) -> Filter: """Update an existing JQL filter associated with the Jira project. :param filter_id: id for the filter :param new_query: filter criteria in the form of a JQL string. :return: """ try: result = self.jira.update_filter(filter_id, jql=new_query) except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to update Jira filter.') else: return result def get_project(self) -> Project: """Get active Jira project. :return: JSON object representing entire Jira project, otherwise None """ try: projects = self.jira.projects() except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira projects.') else: return next( filter(lambda proj: proj.key.upper() == self.project_key, projects), None) def get_board(self, project_key: str) -> Board: """Get active Jira board given a project_key. :param project_key: Jira project key :return: JSON object representing entire Jira board, otherwise None """ try: boards = self.jira.boards() # (project_key) except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira projects.') else: return next( filter( lambda board: board.name.lower() == 'medhub development', boards), None) def get_current_sprint(self, board_id: int) -> Sprint: """Given a board_id, get the current sprint the project is in. Also try and filter out that Moodle shit. :param board_id: ID for the current Jira board :return: """ try: sprints = self.jira.sprints(board_id) except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira sprints.') else: res = next( filter( lambda story: story.state.lower() == 'active' and 'yaks' in story.name.lower(), sprints), None) if res is None: raise JiraBoardException('[!] No current sprint') return res def get_jql_filter(self, filter_id: int) -> Filter: """ :param filter_id: :return: """ try: j_filter = self.jira.filter(filter_id) except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira filter.') else: return j_filter def get_issues_from_filter(self, filter_id: int) -> list: """Get Jira issues returned by a given JQL filter_id. :param filter_id: id for the filter :return: """ jql = self.get_jql_filter(filter_id).jql try: issues = self.jira.search_issues(jql, maxResults=100) except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get issues from Jira filter.') else: return issues def get_issues(self, filter_id: int) -> list: """Seems weirdly like a dupe of get_issues_from_filter(), but if things are working I don't wanna break it right now.. :param filter_id: id for the filter :return: """ if self.testMode: jsonData = open(os.path.join('testJiraData.json'), 'r', encoding='utf-8') issues = json.loads(jsonData.read()) else: try: issues = self.get_issues_from_filter(filter_id) except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get issues from Jira filter.') return issues def get_issue(self, issue_key: str, fields: str = 'status') -> Issue: """Get a Jira story given a key. :param issue_key: a Jira story key :param fields: :return: """ try: issue = self.jira.issue(issue_key, fields=fields, expand='changelog') except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira issue.') else: if issue is None: raise JiraBoardException( f'[!] Unable to retrieve issue {issue_key}') return issue def get_current_status(self, issue_key: str) -> str or None: """Get the current status for a given Jira story. :param issue_key: a Jira story key :return: """ try: issue = self.jira.issue(issue_key, fields='status', expand='changelog') except JIRAError as e: print( f'[!] {str(e)}: Failed to get Jira issue.\nRetrying in a few seconds.' ) raise JiraBoardException else: if issue is not None: return issue.fields.status.name else: return None def get_current_status_category(self, issue_key: str) -> str or None: """Get the status category for a given Jira story. :param issue_key: a Jira story key :return: """ try: issue = self.jira.issue(issue_key, fields='status', expand='changelog') except JIRAError as e: print(f'[!] {str(e)}: Failed to get Jira issue.') raise JiraBoardException else: if issue is not None: return issue.fields.status.statusCategory.name else: return None def get_most_recent_status_change(self, issue_key: str) -> str or None: """Get the most recent status change for a Jira story, given it's issue_key. :param issue_key: a Jira story key :return: object representing the most recent status change """ try: issue = self.jira.issue(issue_key, fields='status', expand='changelog') except JIRAError as e: print(f'[!] {str(e)}: Failed to get Jira issue.') raise JiraBoardException else: if issue is not None: return next( filter(lambda s: s['toString'], issue.fields.status.name), None) else: return None def is_hotfix(self, issue_key: str) -> bool: """Given am issue_key, check if the story is a hotfix. :param issue_key: a Jira story key :return boolean: True if story is a hotfix, otherwise False """ try: issue = self.jira.issue(issue_key, fields='labels', expand='changelog') except JIRAError as e: print( f'[!] {str(e)}: Failed to get Jira issue.\nRetrying in a few seconds.' ) raise JiraBoardException else: if issue is not None: return next( filter(lambda l: l.lower() == 'hotfix', issue.fields.labels), None) is not None else: return False def is_in_staging(self, issue_key: str, stories: list) -> bool: """Given am issue_key, check if the story is in the staging branch. :param issue_key: a Jira story key :param stories: a list of Jira stories :return boolean: True if story is in staging, otherwise False """ in_staging = False issue = next( filter(lambda story: story['jira_key'] == issue_key, stories), None) if issue is not None: in_staging = issue['in_staging'] return in_staging def is_fresh_qa_ready(self, issue_key: str) -> bool: """Given am issue_key, check if the story is ready for QA for the very first time. :param issue_key: a Jira story key :return boolean: True if the story has a current status of "Ready for QA Release" and no fail statuses in the past, otherwise False """ current_status = self.get_current_status(issue_key) qa_fail = self.has_failed_qa(issue_key) if current_status is None: raise JiraBoardException('[!] No current status found') return current_status == 'Ready for QA Release' and not qa_fail def is_stale_qa_ready(self, issue_key: str) -> bool: """Given am issue_key, check if the story is ready for QA after previously failing testing. :param issue_key: a Jira story key :return boolean: True if the story has a current status of 'Ready for QA Release' and at least one prior fail status, otherwise False """ current_status = self.get_current_status(issue_key) qa_fail = self.has_failed_qa(issue_key) if current_status is None: raise JiraBoardException('[!] No current status found') return current_status == 'Ready for QA Release' and qa_fail def is_in_qa_testing(self, issue_key: str) -> bool: """Given am issue_key, check if the story is currently in 'QA Testing'. :param issue_key: a Jira story key :return boolean: True if the story has a current status of 'QA Testing', otherwise False """ current_status = self.get_current_status(issue_key) if current_status is None: raise JiraBoardException('[!] No current status found') return current_status == 'QA Testing' def has_complete_status(self, issue_key: str) -> bool: """Given am issue_key, check if the story has a 'Complete' status category. :param issue_key: a Jira story key :return boolean: True if the story has a current status category of 'Done', otherwise False """ current_status_category = self.get_current_status_category(issue_key) if current_status_category is None: raise JiraBoardException('[!] No current status category found') return current_status_category == 'Done' def for_qa_team(self, issue_key: str) -> bool: """Similar to passed_qa but checks if a QA tester moved this at any point to make sure it actually got tested. :param issue_key: a Jira story key :return boolean: True if the story has a status change with a valid QA tester, otherwise False """ try: issue = self.jira.issue(issue_key, fields='status', expand='changelog') except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira issue.') else: if issue is None: raise JiraBoardException('[!] NoneType issue is not valid.') else: # Let's try this another way statuses = self.get_statuses(issue.changelog.histories) tester = next( filter( lambda s: s['authorName'] in self.testers and 'qa release' in s['fromString'].lower( ) and 'qa testing' in s['toString'].lower(), statuses), None) return tester is not None def passed_qa(self, issue_key: str) -> bool: """Given an issue_key, check if the story has passed QA. :param issue_key: a Jira story key :return boolean: True if the story has a passing status with a valid QA tester, otherwise False """ try: issue = self.jira.issue(issue_key, fields='status', expand='changelog') except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira issue.') else: if issue is None: raise JiraBoardException('[!] NoneType issue is not valid.') else: statuses = self.get_statuses(issue.changelog.histories) tester = next( filter(lambda s: s['authorName'] in self.testers, statuses), None) return self.has_complete_status( issue.key) and tester is not None def has_failed_qa(self, issue_key: str) -> bool: """Given an issue_key, check if the story has failed QA in the past. :param issue_key: a Jira story key :return boolean: True if the story has a fail status in the past, otherwise False """ try: issue = self.jira.issue(issue_key, fields='status', expand='changelog') except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira issue.') else: if issue is None: raise JiraBoardException('[!] NoneType issue is not valid.') else: statuses = self.get_statuses(issue.changelog.histories) failStatus = next( filter( lambda status: (status['authorName'] in self.testers) and status['fromString'] == 'QA Testing' and status[ 'toString'] == 'In Progress', statuses), None) return True if failStatus is not None else False def is_currently_failed(self, issue_key: str) -> bool: """Given an issue_key, check if a Jira story currently has a failure status. :param issue_key: a Jira story key :return boolean: True if the story has a current fail status, otherwise False """ current_status = self.get_current_status(issue_key) if current_status is None: raise JiraBoardException('[!] No current status found') return current_status in ['In Progress', 'Backlog'] def is_defect(self, issue_key: str) -> bool: """Return True if Jira issuetype is a defect. :param issue_key: :return: """ return self.get_issue( issue_key, fields='issuetype').fields.issuetype.name.lower() == 'defect' def is_qa_task(self, issue_key: str) -> bool: """Return True if Jira issuetype is a defect. :param issue_key: :return: """ return self.get_issue( issue_key, fields='issuetype').fields.issuetype.name.lower() == 'qa task' def is_bug(self, issue_key: str) -> bool: """Return True if Jira issuetype is a bug. :param issue_key: :return: """ return self.get_issue( issue_key, fields='issuetype').fields.issuetype.name.lower() == 'bug' def get_statuses(self, change_log: dict or list) -> list: """Get all status changes from a change_log associated with a Jira story. :param change_log: expanded list of fields returned from Jira API for a single issue :return list: a list of dicts representing the status change and it's author """ return [ dict(authorName=ch.author.displayName, created=ch.created, fromString=ch.items[0].fromString, toString=ch.items[0].toString) for ch in change_log if ch.items[0].field.lower() == 'status' ] def get_subtasks(self, issue_key: str) -> list: """Get subtasks of a Jira story. :param issue_key: a Jira story key :return list: a list of subtask names """ try: issue = self.jira.issue(issue_key, fields='status', expand='changelog') except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira issue.') else: if issue is None: raise JiraBoardException('[!] NoneType issue is not valid.') else: return [task.lower() for task in issue.fields.subtasks] def get_attachments(self, issue_key: str) -> list: """Get attachments from a Jira story. :param issue_key: a Jira story key :return list: a list of URLs pointing to story attachments """ try: issue = self.jira.issue(issue_key, fields='attachment', expand='changelog') except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira issue.') else: if issue is None: raise JiraBoardException('[!] NoneType issue is not valid.') else: return [ '{}secure/attachment/{}/{}'.format(self.host, a.id, a.filename) for a in issue.fields.attachment ] def get_labels(self, issue_key: str) -> list: """Get a list of labels attached to a story by issue_key. :param issue_key: a Jira story key :return list: a list Jira story labels """ try: issue = self.jira.issue(issue_key, fields='labels', expand='changelog') except JIRAError as e: raise JiraBoardException( f'[!] {str(e)}: Failed to get Jira issue.') else: if issue is None: raise JiraBoardException('[!] NoneType issue is not valid.') else: return [label.lower() for label in issue.fields.labels] def get_parsed_stories(self, raw_issues: list, testrail_mode: bool = False) -> list: """Given a collection of raw Jira stories, parses them down to JSON objects containing needed fields only. :param raw_issues: JSON collection of stories returned from the Jira API :param testrail_mode: Should QA dates be ignored? If they're in the release but haven't been QAed yet... yes. :return parsed_stories: a list of parsed stories ready for one of the reconcile methods """ parsed_stories = [] for issue in raw_issues: _story = self.get_issue( issue.key, fields= 'summary,description,comment,labels,created,updated,status') _testedBy = 'unassigned' _hasFailed = False _currentStatus = self.get_current_status(_story.key) _changelog = sorted(_story.changelog.histories, key=lambda ch: ch.created, reverse=True) _statuses = self.get_statuses(_changelog) _validTester = next( filter(lambda status: status['authorName'] in self.testers, _statuses), None) _testedBy = _validTester[ 'authorName'] if _validTester is not None else 'unassigned' _url = urljoin(self.host, 'browse/{}'.format(_story.key)) _labels = self.get_labels(_story.key) if _story.fields.summary is not None: _summary = ''.join( re.findall(r'[^*`#\t\'"]', _story.fields.summary)) else: _summary = '' if _story.fields.description is not None: _desc = ''.join( re.findall(r'[^*`#\t\'"]', _story.fields.description)) else: _desc = '' if testrail_mode: record = dict( jira_key=_story.key, jira_url=_url, jira_summary=_summary, jira_desc=_desc, labels=_labels, jira_created=_story.fields.created, jira_updated=_story.fields.updated, tested_by=_testedBy, ) else: _qaStatuses = list( filter( lambda status: status['fromString'] == 'QA Testing' or status['toString'] == 'QA Testing', _statuses)) _validTester = next( filter(lambda status: status['authorName'] in self.testers, _statuses), None) _qaDateStatus = next( filter( lambda status: status['fromString'] == 'In Progress' and status['toString'] == 'Ready for QA Release' and _story.key not in parsed_stories, _statuses), None) if _qaDateStatus is None: if self.is_defect(_story.key) or self.is_qa_task( _story.key): # defects or QA tasks don't go on the board continue print( f'[!] QA date not found on {str(issue.key)}\n\tEither the issue type did not get excluded or a developer may have accidentally moved the story into QA' ) _movedToQaDate = dateutil.parser.parse( _story.fields.updated) else: _movedToQaDate = dateutil.parser.parse( _qaDateStatus['created']) _hasFailed = True if len(_qaStatuses) > 0 else False _testedBy = _validTester[ 'authorName'] if _validTester is not None else 'unassigned' _comments = '\n'.join([ '\n_**{}** at {}_:\n\n{}\n\n'.format( comment.author, dateutil.parser.parse( comment.updated).strftime('%Y-%m-%d %H:%M'), ''.join(re.findall(r'[^*`#\t\'"]', comment.body))) for comment in sorted(_story.fields.comment.comments, key=lambda s: s.updated, reverse=True) ]) _statuses = '\n'.join([ '_**{}**_:\n\nFrom {} to {} at {}\n\n'.format( s['authorName'], s['fromString'], s['toString'], dateutil.parser.parse( s['created']).strftime('%Y-%m-%d %H:%M')) for s in _statuses ]) if _movedToQaDate is not None: _qaDate = _movedToQaDate.strftime('%Y-%m-%d %H:%M:%S%z') else: raise JiraBoardException('[!] No QA date status found') _api_url = urljoin(self.host, 'rest/api/2/issue/{}'.format(_story.key)) _hotfix = self.is_hotfix(_story.key) _inStaging = False _attachments = self.get_attachments(_story.key) record = dict( jira_id=issue.id, jira_key=_story.key, jira_url=_url, jira_api_url=_api_url, jira_summary=_summary, jira_desc=_desc, jira_created=_story.fields.created, jira_updated=_story.fields.updated, jira_qa_date=_qaDate, tested_by=_testedBy, current_status=_currentStatus, has_failed=_hasFailed, in_staging=_inStaging, is_hotfix=_hotfix, comments=_comments, statuses=_statuses, labels=_labels, attachments=_attachments, last_known_commit_date=None, git_commit_message=None, ) parsed_stories.append(record) if testrail_mode: result = sorted(parsed_stories, key=lambda story: story['jira_updated']) else: result = sorted(parsed_stories, key=lambda story: story['jira_qa_date']) return result