diff --git a/scripts/tripleo-critical-bugs/README.md b/scripts/tripleo-critical-bugs/README.md new file mode 100644 index 000000000..da4cf9f48 --- /dev/null +++ b/scripts/tripleo-critical-bugs/README.md @@ -0,0 +1,4 @@ +Tool used to automated CIX escalations for Red Hat Openstack TripleO group. + + +./statusreport.py --trello_token $token --trello_api_key $key --trello_board_id $board_id diff --git a/scripts/tripleo-critical-bugs/config/critical-alert-escalation.cfg.sample b/scripts/tripleo-critical-bugs/config/critical-alert-escalation.cfg.sample new file mode 100644 index 000000000..d0a0531b9 --- /dev/null +++ b/scripts/tripleo-critical-bugs/config/critical-alert-escalation.cfg.sample @@ -0,0 +1,16 @@ +# Each line is a launchpad project name, filter regex +[LaunchpadBugs] +TripleO=tripleo,.* +#Heat=heat,.* + +[TrelloConfig] +# now in the cli +#token= +#api_key= +#board_id= +list_outtage=Critical PChain Outage +list_tech_debt=Release Blocker Technical Debt +list_new=New / Triage + +[Bug] +delay: 5 diff --git a/scripts/tripleo-critical-bugs/reports/__init__.py b/scripts/tripleo-critical-bugs/reports/__init__.py new file mode 100644 index 000000000..9dd8c3ae1 --- /dev/null +++ b/scripts/tripleo-critical-bugs/reports/__init__.py @@ -0,0 +1,12 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/scripts/tripleo-critical-bugs/reports/erhealth.py b/scripts/tripleo-critical-bugs/reports/erhealth.py new file mode 100644 index 000000000..063bc2d54 --- /dev/null +++ b/scripts/tripleo-critical-bugs/reports/erhealth.py @@ -0,0 +1,11 @@ +import requests + +base_url = "https://opendev.org/openstack/tripleo-ci-health-queries/raw/branch/master/output/elastic-recheck/" +health_url = "http://health.sbarnea.com/" + + +def get_health_link(bug_id): + response = requests.get(base_url + str(bug_id) + ".yaml") + if response.status_code == 200: + return health_url + "#" + str(bug_id) + return "" diff --git a/scripts/tripleo-critical-bugs/reports/launchpad.py b/scripts/tripleo-critical-bugs/reports/launchpad.py new file mode 100644 index 000000000..7fceaa9d9 --- /dev/null +++ b/scripts/tripleo-critical-bugs/reports/launchpad.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python + +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import datetime +from datetime import timedelta +import pytz +import re + +from launchpadlib.launchpad import Launchpad + +""" this returns a list of launchpad bugs """ + + +class LaunchpadReport(object): + def __init__(self, bugs, config): + self.bugs = bugs + self.config = config + + def generate(self): + bugs_with_alerts_open = {} + bugs_with_alerts_closed = {} + launchpad = Launchpad.login_anonymously( + 'Red Hat Status Bot', 'production', '.cache', version='devel' + ) + bug_statuses_open = ['Confirmed', 'Triaged', 'In Progress', 'Fix Committed'] + bug_statuses_closed = ['Fix Released'] + for label, config_string in self.bugs.items(): + + c = config_string.split(',') + project = launchpad.projects[c[0]] + filter_re = c[1] + for milestone in project.all_milestones: + if re.match(filter_re, milestone.name): + for task in project.searchTasks( + milestone=milestone, + status=bug_statuses_open, + tags='promotion-blocker', + ): + now = datetime.datetime.now(pytz.UTC) + delay = int(self.config.get('Bug', 'delay')) + delay_time = now - timedelta(hours=delay) + if delay_time > task.date_created: + bugs_with_alerts_open[task.bug.id] = task.bug + + for task in project.searchTasks( + milestone=milestone, + status=bug_statuses_closed, + importance='Critical', + tags='alert', + ): + + bugs_with_alerts_closed[task.bug.id] = task.bug + return bugs_with_alerts_open, bugs_with_alerts_closed diff --git a/scripts/tripleo-critical-bugs/reports/trello.py b/scripts/tripleo-critical-bugs/reports/trello.py new file mode 100644 index 000000000..5679e42a4 --- /dev/null +++ b/scripts/tripleo-critical-bugs/reports/trello.py @@ -0,0 +1,265 @@ +#!/usr/bin/python + +import json +import os +from datetime import datetime + +import dateutil.parser +import pytz +import requests +from dateutil.relativedelta import relativedelta + +BOARD_BLACKLIST = os.environ.get('TRELLO_BOARD_BLACKLIST') + +# +# API CONTEXT OBJECT +# + + +class ApiContext(object): + def __init__(self, config): + self.config = config + self._apiVersion = 1 + self.apiToken = config.get('TrelloConfig', 'token') + self.apiKey = config.get('TrelloConfig', 'api_key') + self._payload = {'key': self.apiKey, 'token': self.apiToken} + + @property + def ApiRootUrl(self): + return "https://trello.com/%s" % self._apiVersion + + @property + def Payload(self): + return self._payload + + +# +# BOARDS +# +class Boards(object): + def __init__(self, context): + self._api = context + + # note: it's not currently possible to nuke a board, only to close it + def create(self, name, description=None): + "create a new board." + response = requests.post( + "%s/boards" % self._api.ApiRootUrl, + params=self._api.Payload, + data=dict(name=name, desc=description), + ) + response.raise_for_status() + jsonResponse = json.loads(response.text) + return jsonResponse + + def get_all_by_member(self, memberNameOrId): + "Obtain data struct for a board by name." + + memberBoardsUrl = '{0}/members/{1}/boards'.format( + self._api.ApiRootUrl, memberNameOrId + ) + response = requests.get(memberBoardsUrl, params=self._api.Payload) + response.raise_for_status() + jsonResponse = json.loads(response.text) + return jsonResponse + + def get_name(self, boardId): + "Obtain data struct for a board by name." + + memberBoardsUrl = '{0}/boards/{1}'.format(self._api.ApiRootUrl, boardId) + response = requests.get(memberBoardsUrl, params=self._api.Payload) + response.raise_for_status() + jsonResponse = json.loads(response.text) + return jsonResponse['name'] + + def get_all_by_member_and_name( + self, memberNameOrId, boardName, raiseExceptionIfDuplicates=True + ): + "Get all boards for a member, and return those matching a name (handles duplicate names)." + boards = self.get_all_by_member(memberNameOrId) + boardsToReturn = [b for b in boards if b['name'] == boardName] + + if raiseExceptionIfDuplicates: + if len(boardsToReturn) != 1: + raise AssertionError( + "ERROR: get_all_by_member_and_name({0}, {1}) - NO DUPES ALLOWED".format( + memberNameOrId, boardName + ) + ) + + return boardsToReturn + + def get_lists(self, boardId): + "Get all lists on a board." + + boardListsUrl = '{0}/boards/{1}/lists'.format(self._api.ApiRootUrl, boardId) + resp = requests.get(boardListsUrl, params=self._api.Payload) + resp.raise_for_status() + jsonResp = json.loads(resp.text) + return jsonResp + + def get_cards(self, boardId): + "Get all cards on a board." + + boardListsUrl = '{0}/boards/{1}/cards'.format(self._api.ApiRootUrl, boardId) + resp = requests.get(boardListsUrl, params=self._api.Payload) + resp.raise_for_status() + jsonResp = json.loads(resp.text) + return jsonResp + + def get_lists_by_name(self, boardId, listName, raiseExceptionIfDuplicates=True): + "Get all lists associated with a board by name." + lists = self.get_lists(boardId) + listsToReturn = [lst for lst in lists if lst['name'] == listName] + + if raiseExceptionIfDuplicates: + if len(listsToReturn) != 1: + raise AssertionError( + "ERROR: get_lists_by_name({0}, {1}) - NO DUPES ALLOWED".format( + boardId, listName + ) + ) + + return listsToReturn + + def get_lists_by_id(self, boardId, listId, raiseExceptionIfDuplicates=True): + "Get all lists associated with a board by id." + lists = self.get_lists(boardId) + listsToReturn = [lst for lst in lists if lst['id'] == listId] + + # if raiseExceptionIfDuplicates == True: + # if len(listsToReturn) != 1: + # raise AssertionError("ERROR: get_lists_by_id({0}, {1}) - NO DUPES ALLOWED".format(boardId, listId)) + + return listsToReturn + + # TODO: for now do this expensive way (getting everything and filtering) vs. a nice nuanced query + def get_single_by_member_and_name(self, memberNameOrId, boardName): + board = self.get_all_by_member_and_name(memberNameOrId, boardName) + id = board[0]['id'] + return id + + def get_single_list_by_name(self, boardId, listName): + lists = self.get_lists_by_name(boardId, listName) + id = lists[0]['id'] + return id + + def get_single_list_by_id(self, boardId, listId): + lists = self.get_lists_by_id(boardId, listId) + try: + name = lists[0]['name'] + except IndexError: + name = "unknown" + return name + + +# +# MEMBERS +# +class Members(object): + def __init__(self, context): + self._api = context + + def get_member(self, memberName): + "Get member data based on name" + membersUrl = '{0}/members/{1}'.format(self._api.ApiRootUrl, memberName) + response = requests.get(membersUrl, params=self._api.Payload) + response.raise_for_status() + return json.loads(response.text) + + def get_member_id(self, memberName): + "Get member id based on name" + return self.get_member(memberName)['id'] + + def get_member_name(self, memberId): + "Get member name based on id" + return self.get_member(memberId)['fullName'].encode('ascii', 'ignore') + + def get_member_names_from_list(self, memberId): + "Get member name based on id" + if isinstance(memberId, list): + names = [self.get_member_name(member) for member in memberId] + return ', '.join(names) + else: + raise TypeError() + + +# +# CARDS +# +class Cards(object): + def __init__(self, context): + self._api = context + + def get_card(self, cardId): + cardUrl = '{0}/cards/{1}'.format(self._api.ApiRootUrl, cardId) + response = requests.get(cardUrl, params=self._api.Payload) + response.raise_for_status() + return json.loads(response.text) + + def get_card_due_date(self, cardId): + "Get member id based on name" + return self.get_card(cardId)['due'] + + def get_card_labels(self, cardId): + return self.get_card(cardId)['labels'] + + def get_card_members(self, cardId): + return self.get_card(cardId)['idMembers'] + + def create(self, name, idList, due=None, desc=None): + "create a new card, optionally setting a due date and description." + response = requests.post( + "%s/cards" % self._api.ApiRootUrl, + params=self._api.Payload, + data=dict(name=name, idList=idList, due=due, desc=desc), + ) + response.raise_for_status() + return json.loads(response.text) + + def add_comment_to_card(self, cardId, comment): + "Add a member to a card" + postMemberToCardUrl = '{0}/cards/{1}/actions/comments'.format( + self._api.ApiRootUrl, cardId + ) + response = requests.post( + postMemberToCardUrl, params=self._api.Payload, data={'text': comment} + ) + response.raise_for_status() + return json.loads(response.text) + + def add_due_date_to_card(self, card, date): + "Add a due date to a trello card" + putDueDateToCardUrl = '{0}/cards/{1}'.format(self._api.ApiRootUrl, card['id']) + response = requests.put( + putDueDateToCardUrl, params=self._api.Payload, data={'due': date} + ) + response.raise_for_status() + return json.loads(response.text) + + def check_card_overdue(self, cardId, blocking_labels, overdue_notice): + now = datetime.now(pytz.utc) + due = dateutil.parser.parse(self.get_card_due_date(cardId)) + delta = relativedelta(now, due) + if delta.days > 0 or delta.months > 0: + if not self.check_card_blocked_label(cardId, blocking_labels): + self.add_comment_to_card(cardId, overdue_notice) + return True + else: + return False + + def check_card_blocked_label(self, cardId, blocking_labels): + labels = self.get_card_labels(cardId) + if [label for label in labels if label['name'] in blocking_labels]: + return True + else: + return False + + def get_cards(self, listId, filterArg="all"): + "Get cards on a given list" + getCardsUrl = '{0}/lists/{1}/cards/{2}'.format( + self._api.ApiRootUrl, listId, filterArg + ) + response = requests.get(getCardsUrl, params=self._api.Payload) + response.raise_for_status() + return json.loads(response.text) diff --git a/scripts/tripleo-critical-bugs/requirements.txt b/scripts/tripleo-critical-bugs/requirements.txt new file mode 100644 index 000000000..104bf2ffb --- /dev/null +++ b/scripts/tripleo-critical-bugs/requirements.txt @@ -0,0 +1,7 @@ +click +configparser +launchpadlib==1.10.5 +python-dateutil +#pytz==2015.7 +pytz +requests diff --git a/scripts/tripleo-critical-bugs/statusreport.py b/scripts/tripleo-critical-bugs/statusreport.py new file mode 100755 index 000000000..88fb26181 --- /dev/null +++ b/scripts/tripleo-critical-bugs/statusreport.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python + +# The MIT License (MIT) +# Copyright (c) 2017 Wes Hayutin +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +takes a list of launchpad bugs that have the tag 'promotion-blocker' +then compares the list to cards on the cix trello board. If there +is a lp bug that does not have a card it will open a trello card +""" + +import configparser + +import click + +import reports.trello as trello +from reports.erhealth import get_health_link +from reports.launchpad import LaunchpadReport + + +class StatusReport(object): + """ + compares a list of launchpad bugs to a list + of trello cards. + """ + + def __init__(self, config): + + self.config = config + self.brief_status = {} + self.detailed_status = {} + + def summarise_launchpad_bugs(self): + """ + a list of open bugs with promotion-blocker + also returns a list of closed bugs if action needs to + be taken + """ + if not self.config.has_section('LaunchpadBugs'): + return + + bugs = self.config["LaunchpadBugs"] + report = LaunchpadReport(bugs, self.config) + bugs_with_alerts_open, bugs_with_alerts_closed = report.generate() + return bugs_with_alerts_open, bugs_with_alerts_closed + + def print_report(self, bug_list): + """print the bugs to the console""" + bug_number_list = [] + for key, value in bug_list.items(): + print(key) + bug_number_list.append(str(key)) + return bug_number_list + + def _get_config_items(self, section_name, prefix=None): + if not self.config.has_section(section_name): + return {} + + items = { + k: v + for (k, v) in self.config.items(section_name) + if not k.startswith('_') and (prefix is None or k.startswith(prefix)) + } + return items + + def compare_bugs_with_cards(self, list_of_bugs, cards): + """ + compare a list of bugs to trello cards by checking for + the bug number in the title of the trello card + """ + open_bugs = list_of_bugs + cards_outtage_names = [] + for card in cards: + cards_outtage_names.append(card['name']) + print(card['name'].encode('utf-8')) + + match = [] + for card in cards_outtage_names: + for key in open_bugs: + key = str(key) + if key in card: + match.append(int(key)) + print("##########################################") + print("openbugs " + str(set(open_bugs))) + print("match " + str(set(match))) + critical_bugs_with_out_escalation_cards = list(set(open_bugs) - set(match)) + return critical_bugs_with_out_escalation_cards + + def create_escalation( + self, config, critical_bugs_with_out_escalation_cards, list_of_bugs, trello_list + ): + """ + create a trello card in the triage list of the cix board + """ + if not critical_bugs_with_out_escalation_cards: + print("There are no bugs that require a new escalation") + else: + # send email to list + for bug in critical_bugs_with_out_escalation_cards: + bug_title = list_of_bugs[bug].title + bug_link = list_of_bugs[bug].web_link + health_link = get_health_link(list_of_bugs[bug].id) + + card_title = ( + "[CIX][LP:" + str(bug) + "][tripleoci][proa] " + str(bug_title) + ) + + # create escalation card + trello_api_context = trello.ApiContext(config) + trello_cards = trello.Cards(trello_api_context) + trello_cards.create( + card_title, trello_list, desc=bug_link + "\n" + health_link + ) + + +@click.command() +@click.option( + "--config_file", + default="config/critical-alert-escalation.cfg", + help="Defaults to 'config/critical-alert-escalation.cfg'", +) +@click.option("--trello_token", required=True, help="Your Trello Token") +@click.option("--trello_api_key", required=True, help="Your Trello api key") +@click.option("--trello_board_id", required=True, help="The trello board id") +def main(config_file, trello_token, trello_api_key, trello_board_id): + """ + get the list of promotion-blocker bugs + compare the list to trello + create cards as needed + """ + + config = configparser.ConfigParser() + config.read(config_file) + config['TrelloConfig']['token'] = trello_token + config['TrelloConfig']['api_key'] = trello_api_key + config['TrelloConfig']['board_id'] = trello_board_id + + report = StatusReport(config) + + bugs_with_alerts_open, bugs_with_alerts_closed = report.summarise_launchpad_bugs() + + print("*** open critical bugs ***") + report.print_report(bugs_with_alerts_open) + print("*** closed critical bugs ***") + report.print_report(bugs_with_alerts_closed) + + trello_api_context = trello.ApiContext(config) + trello_boards = trello.Boards(trello_api_context) + + trello_new_list = trello_boards.get_lists_by_name( + config.get('TrelloConfig', 'board_id'), config.get('TrelloConfig', 'list_new') + ) + trello_new_list_id = str(trello_new_list[0]['id']) + + all_cards_on_board = trello_boards.get_cards(config.get('TrelloConfig', 'board_id')) + print("all cards " + str(len(all_cards_on_board))) + cards_outtage = all_cards_on_board + + critical_bugs_with_out_escalation_cards = report.compare_bugs_with_cards( + bugs_with_alerts_open, cards_outtage + ) + print( + "critical bugs not tracked on board " + + str(critical_bugs_with_out_escalation_cards) + ) + + report.create_escalation( + config, + critical_bugs_with_out_escalation_cards, + bugs_with_alerts_open, + trello_new_list_id, + ) + + +if __name__ == '__main__': + main() diff --git a/zuul.d/multinode-jobs.yaml b/zuul.d/multinode-jobs.yaml index a2ceb8334..bf6f8cb8f 100644 --- a/zuul.d/multinode-jobs.yaml +++ b/zuul.d/multinode-jobs.yaml @@ -64,6 +64,7 @@ # tripleo-ansible - ^_skeleton_role_/.* - ^scripts/.* + - ^scripts/tripleo-critical-bugs/.* - ^tox.ini$ - ^tripleo_ansible/playbooks/docker-vfs-setup.yml$ - ^tripleo_ansible/.*molecule.* @@ -173,6 +174,7 @@ - ^playbooks/tripleo-buildcontainers/.*$ - ^playbooks/tripleo-buildimages/.*$ - ^vars/sova-patterns.yml$ + - ^scripts/tripleo-critical-bugs/.* dependencies: - openstack-tox-linters - tripleo-ci-centos-8-content-provider-victoria: diff --git a/zuul.d/standalone-jobs.yaml b/zuul.d/standalone-jobs.yaml index 64d09712e..c536e5459 100644 --- a/zuul.d/standalone-jobs.yaml +++ b/zuul.d/standalone-jobs.yaml @@ -50,6 +50,7 @@ - ^test-requirements.txt$ - ^vars/sova-patterns.yml$ - tox.ini + - ^scripts/.* # like parent but with requirements.txt and setup.py removed - job: