commit 0ec554a1c6a11c27379545c47937364f6748ff6b Author: Nils Philippsen Date: Mon Nov 18 11:12:22 2019 +0100 import from Fedora infra ansible repo Signed-off-by: Nils Philippsen diff --git a/pagure-sync-bugzilla.py.j2 b/pagure-sync-bugzilla.py.j2 new file mode 100644 index 0000000..0a55a86 --- /dev/null +++ b/pagure-sync-bugzilla.py.j2 @@ -0,0 +1,828 @@ +#!/usr/bin/python -tt +# -*- coding: utf-8 -*- +# +# Copyright © 2013-2017 Red Hat, Inc. +# +# This copyrighted material is made available to anyone wishing to use, modify, +# copy, or redistribute it subject to the terms and conditions of the GNU +# General Public License v.2, or (at your option) any later version. This +# program is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY expressed or implied, including the implied warranties of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. You should have received a copy of the GNU +# General Public License along with this program; if not, write to the Free +# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. Any Red Hat trademarks that are incorporated in the source +# code or documentation are not subject to the GNU General Public License and +# may only be used or replicated with the express permission of Red Hat, Inc. +# +# Red Hat Author(s): Toshio Kuratomi +# Author(s): Mike Watters +# Author(s): Pierre-Yves Chibon +# Author(s): Matt Prahl +# Author(s): Ralph Bean +# +''' +sync information from the Pagure into bugzilla + +This ... script takes information about package onwership and imports it +into bugzilla. +''' +from __future__ import print_function +import re +import argparse +import datetime +import time +import sys +import os +import itertools +import json +import xmlrpclib +import codecs +import smtplib +import traceback +import multiprocessing.pool +try: + from email.Message import Message +except ImportError: + from email.message import EmailMessage as Message + +import bugzilla as bugzilla_lib +import dogpile.cache +import requests +import yaml +from six import string_types +import fedora.client +from fedora.client.fas2 import AccountSystem + +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry + + +cache = dogpile.cache.make_region().configure( + 'dogpile.cache.memory', + expiration_time=3600, +) + + +def retry_session(): + session = requests.Session() + retry = Retry( + total=5, + read=5, + connect=5, + backoff_factor=0.3, + status_forcelist=(500, 502, 504), + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount('http://', adapter) + session.mount('https://', adapter) + return session + + +{% if env == 'staging' %} +BZSERVER = 'https://partner-bugzilla.redhat.com' +{% else %} +BZSERVER = 'https://bugzilla.redhat.com' +{% endif %} + +BZUSER = '{{ bugzilla_user }}' +BZPASS = '{{ bugzilla_password }}' +BZCOMPAPI = 'component.get' +FASUSER = '{{ fedorathirdpartyUser }}' +FASPASS = '{{ fedorathirdpartyPassword }}' +BUGZILLA_OVERRIDE_REPO = 'releng/fedora-scm-requests' +NOTIFYEMAIL = [ + 'kevin@fedoraproject.org', + 'pingou@fedoraproject.org', +] +VERBOSE = False +DRYRUN = False + +{% if env == 'staging' %} +FASURL = 'https://admin.stg.fedoraproject.org/accounts' +FASINSECURE = True +PAGUREURL = 'https://stg.pagure.io' +PAGURE_DIST_GIT_URL = 'https://src.stg.fedoraproject.org' +PDCURL = 'https://pdc.stg.fedoraproject.org/rest_api/v1/' +MDAPIURL = 'https://apps.stg.fedoraproject.org/mdapi/' +{% else %} +FASURL = 'https://admin.fedoraproject.org/accounts' +FASINSECURE = False +PAGUREURL = 'https://pagure.io' +PAGURE_DIST_GIT_URL = 'https://src.fedoraproject.org' +PDCURL = 'https://pdc.fedoraproject.org/rest_api/v1/' +MDAPIURL = 'https://apps.fedoraproject.org/mdapi/' +{% endif %} + + +EMAIL_FROM = 'accounts@fedoraproject.org' +DATA_CACHE = '/var/tmp/pagure_sync_bz.json' + +PRODUCTS = { + 'Fedora': 'Fedora', + 'Fedora Container': 'Fedora Container Images', + 'Fedora Modules': 'Fedora Modules', + 'Fedora EPEL': 'Fedora EPEL', +} + +NAMESPACE_TO_PRODUCT = { + 'rpms': 'Fedora', # except EPEL... + 'container': 'Fedora Container', + 'modules': 'Fedora Modules', +} + +# This maps bugzilla products to "lead" branches in PDC. If the lead branch is +# retired, then we in turn set the default assignee to "orphan" for all new bugs +# in the given product. +PRODUCTS_TO_LEAD_BRANCH = { + # If rawhide is retired, then all new bugs go to orphan for Fedora. + 'Fedora': 'master', + # Same for containers. + 'Fedora Container': 'master', + # Same for modules. + 'Fedora Modules': 'master', + # If epel7 is retired, then all new epel bugs go to orphan. + 'Fedora EPEL': 'epel7', +} +PDC_TYPES = { + 'rpms': 'rpm', + 'modules': 'module', + 'container': 'container', +} +INVERSE_PDC_TYPES = dict([(v, k) for k, v in PDC_TYPES.items()]) + + +# When querying for current info, take segments of 1000 packages a time +BZ_PKG_SEGMENT = 1000 + + +TMPL_EMAIL_ADMIN = ''' +The following errors were encountered while updating bugzilla with information +from the Package Database. Please have the problems taken care of: + +%s +''' + +# PkgDB sync bugzilla email +PKGDB_SYNC_BUGZILLA_EMAIL = """Greetings. + +You are receiving this email because there's a problem with your +bugzilla.redhat.com account. + +If you recently changed the email address associated with your +Fedora account in the Fedora Account System, it is now out of sync +with your bugzilla.redhat.com account. This leads to problems +with Fedora packages you own or are CC'ed on bug reports for. + +Please take one of the following actions: + +a) login to your old bugzilla.redhat.com account and change the email +address to match your current email in the Fedora account system. +https://bugzilla.redhat.com login, click preferences, account +information and enter new email address. + +b) Create a new account in bugzilla.redhat.com to match your +email listed in your Fedora account system account. +https://bugzilla.redhat.com/ click 'new account' and enter email +address. + +c) Change your Fedora Account System email to match your existing +bugzilla.redhat.com account. +https://admin.fedoraproject.org/accounts login, click on 'my account', +then 'edit' and change your email address. + +If you have questions or concerns, please let us know. + +Your prompt attention in this matter is appreciated. + +The Fedora admins. +""" + + +def resilient_partial(fn, *initial, **kwargs): + """ A decorator that partially applies arguments. + + It additionally catches all raised exceptions, prints them, but then returns + None instead of propagating the failures. + + This is used to protect functions used in a threadpool. If one fails, we + want to know about it, but we don't want it to kill the whole program. So + catch its error, log it, but proceed. + """ + def wrapper(*additional): + try: + full = initial + additional + return fn(*full, **kwargs) + except Exception: + traceback.print_exc() + return None + wrapper.__name__ = fn.__name__ + wrapper.__doc__ = fn.__doc__ + return wrapper + + +class DataChangedError(Exception): + '''Raised when data we are manipulating changes while we're modifying it.''' + pass + + +def segment(iterable, chunk, fill=None): + '''Collect data into `chunk` sized block''' + args = [iter(iterable)] * chunk + return itertools.izip_longest(*args, fillvalue=fill) + + +class ProductCache(dict): + def __init__(self, bz, acls): + self.bz = bz + self.acls = acls + + # Ask bugzilla for a section of the pkglist. + # Save the information from the section that we want. + def __getitem__(self, key): + try: + return super(ProductCache, self).__getitem__(key) + except KeyError: + # We can only cache products we have pagure information for + if key not in self.acls: + raise + + if BZCOMPAPI == 'getcomponentsdetails': + # Old API -- in python-bugzilla. But with current server, this + # gives ProxyError + products = self.bz.getcomponentsdetails(key) + elif BZCOMPAPI == 'component.get': + # Way that's undocumented in the partner-bugzilla api but works + # currently + pkglist = projects_dict[key].keys() + products = {} + for pkg_segment in segment(pkglist, BZ_PKG_SEGMENT): + # Format that bugzilla will understand. Strip None's that + # segment() pads out the final data segment() with + query = [ + dict(product=PRODUCTS[key], component=p) + for p in pkg_segment if p is not None + ] + raw_data = self.bz._proxy.Component.get(dict(names=query)) + for package in raw_data['components']: + # Reformat data to be the same as what's returned from + # getcomponentsdetails + product = dict( + initialowner=package['default_assignee'], + description=package['description'], + initialqacontact=package['default_qa_contact'], + initialcclist=package['default_cc']) + products[package['name'].lower()] = product + self[key] = products + + return super(ProductCache, self).__getitem__(key) + + +class BugzillaProxy(object): + + def __init__(self, bzServer, username, password, acls): + self.bzXmlRpcServer = bzServer + self.username = username + self.password = password + + self.server = bugzilla_lib.Bugzilla( + url=self.bzXmlRpcServer, + user=self.username, + password=self.password) + self.productCache = ProductCache(self.server, acls) + + # Connect to the fedora account system + self.fas = AccountSystem( + base_url=FASURL, + username=FASUSER, + password=FASPASS) + + try: + self.userCache = self.fas.people_by_key( + key='username', + fields=['bugzilla_email']) + except fedora.client.ServerError: + # Sometimes, building the userCache up front fails with a timeout. + # It's ok, we build the cache as-needed later in the script. + self.userCache = {} + + def _get_bugzilla_email(self, username): + '''Return the bugzilla email address for a user. + + First looks in a cache for a username => bugzilla email. If not found, + reloads the cache from fas and tries again. + ''' + try: + return self.userCache[username]['bugzilla_email'].lower() + except KeyError: + if username.startswith('@'): + group = self.fas.group_by_name(username[1:]) + self.userCache[username] = { + 'bugzilla_email': group.mailing_list} + else: + person = self.fas.person_by_username(username) + bz_email = person.get('bugzilla_email', None) + if bz_email is None: + print('%s has no bugzilla email, valid account?' + % username) + else: + self.userCache[username] = {'bugzilla_email': bz_email} + return self.userCache[username]['bugzilla_email'].lower() + + def add_edit_component(self, package, collection, owner, description=None, + qacontact=None, cclist=None): + '''Add or update a component to have the values specified. + ''' + # Turn the cclist into something usable by bugzilla + if not cclist or 'people' not in cclist: + initialCCList = list() + else: + initialCCList = [ + self._get_bugzilla_email(cc) for cc in cclist['people']] + if 'groups' in cclist: + group_cc = [ + self._get_bugzilla_email(cc) for cc in cclist['groups']] + initialCCList.extend(group_cc) + + # Add owner to the cclist so comaintainers taking over a bug don't + # have to do this manually + owner = self._get_bugzilla_email(owner) + if owner not in initialCCList: + initialCCList.append(owner) + + # Lookup product + try: + product = self.productCache[collection] + except xmlrpclib.Fault as e: + # Output something useful in args + e.args = (e.faultCode, e.faultString) + raise + except xmlrpclib.ProtocolError as e: + e.args = ('ProtocolError', e.errcode, e.errmsg) + raise + + pkgKey = package.lower() + if pkgKey in product: + # edit the package information + data = {} + + # Grab bugzilla email for things changable via xmlrpc + if qacontact: + qacontact = self._get_bugzilla_email(qacontact) + else: + qacontact = 'extras-qa@fedoraproject.org' + + # Check for changes to the owner, qacontact, or description + if product[pkgKey]['initialowner'] != owner: + data['initialowner'] = owner + + if description and product[pkgKey]['description'] != description: + data['description'] = description + if product[pkgKey]['initialqacontact'] != qacontact and ( + qacontact or product[pkgKey]['initialqacontact']): + data['initialqacontact'] = qacontact + + if len(product[pkgKey]['initialcclist']) != len(initialCCList): + data['initialcclist'] = initialCCList + else: + for ccMember in product[pkgKey]['initialcclist']: + if ccMember not in initialCCList: + data['initialcclist'] = initialCCList + break + + if data: + # FIXME: initialowner has been made mandatory for some + # reason. Asking dkl why. + data['initialowner'] = owner + + # Changes occurred. Submit a request to change via xmlrpc + data['product'] = PRODUCTS[collection] + data['component'] = package + if VERBOSE: + print('[EDITCOMP] Changing via editComponent(' + '%s, %s, "xxxxx")' % (data, self.username)) + print('[EDITCOMP] Former values: %s|%s|%s|%s' % ( + product[pkgKey]['initialowner'], + product[pkgKey]['description'], + product[pkgKey]['initialqacontact'], + product[pkgKey]['initialcclist'])) + if not DRYRUN: + try: + self.server.editcomponent(data) + except xmlrpclib.Fault as e: + # Output something useful in args + e.args = (data, e.faultCode, e.faultString) + raise + except xmlrpclib.ProtocolError as e: + e.args = ('ProtocolError', e.errcode, e.errmsg) + raise + else: + # Add component + if qacontact: + qacontact = self._get_bugzilla_email(qacontact) + else: + qacontact = 'extras-qa@fedoraproject.org' + + data = { + 'product': PRODUCTS[collection], + 'component': package, + 'description': description or 'NA', + 'initialowner': owner, + 'initialqacontact': qacontact + } + if initialCCList: + data['initialcclist'] = initialCCList + + if VERBOSE: + print('[ADDCOMP] Adding new component AddComponent:(' + '%s, %s, "xxxxx")' % (data, self.username)) + if not DRYRUN: + try: + self.server.addcomponent(data) + except xmlrpclib.Fault as e: + # Output something useful in args + e.args = (data, e.faultCode, e.faultString) + raise + + +def send_email(fromAddress, toAddress, subject, message, ccAddress=None): + '''Send an email if there's an error. + + This will be replaced by sending messages to a log later. + ''' +{% if env == 'staging' %} + # Send no email in staging... + pass +{% else %} + msg = Message() + msg.add_header('To', ','.join(toAddress)) + msg.add_header('From', fromAddress) + msg.add_header('Subject', subject) + if ccAddress is not None: + msg.add_header('Cc', ','.join(ccAddress)) + toAddress = toAddress + ccAddress + msg.set_payload(message) + smtp = smtplib.SMTP('bastion') + smtp.sendmail(fromAddress, toAddress, msg.as_string()) + smtp.quit() +{% endif %} + + +def notify_users(errors): + ''' Browse the list of errors and when we can retrieve the email + address, use it to notify the user about the issue. + ''' + data = {} + if os.path.exists(DATA_CACHE): + try: + with open(DATA_CACHE) as stream: + data = json.load(stream) + except Exception as err: + print('Could not read the json file at %s: \nError: %s' % ( + DATA_CACHE, err)) + + new_data = {} + seen = [] + for error in errors: + notify_user = False + if 'The name ' in error and ' is not a valid username' in error: + user_email = error.split(' is not a valid username')[0].split( + 'The name ')[1].strip() + now = datetime.datetime.utcnow() + + # See if we already know about this user + if user_email in data and data[user_email]['last_update']: + last_update = datetime.datetime.fromtimestamp( + int(data[user_email]['last_update'])) + # Only notify users once per hour + if (now - last_update).seconds >= 3600: + notify_user = True + else: + new_data[user_email] = data[user_email] + elif not data or user_email not in data: + notify_user = True + + # Ensure we notify the user only once, no matter how many errors we + # got concerning them. + if user_email not in seen: + seen.append(user_email) + else: + notify_user = False + + if notify_user: + send_email( + EMAIL_FROM, + [user_email], + subject='Please fix your bugzilla.redhat.com account', + message=PKGDB_SYNC_BUGZILLA_EMAIL, + ccAddress=NOTIFYEMAIL, + ) + + new_data[user_email] = { + 'last_update': time.mktime(now.timetuple()) + } + + with open(DATA_CACHE, 'w') as stream: + json.dump(new_data, stream) + + +@cache.cache_on_arguments() +def _get_override_yaml(project): + pagure_override_url = '{0}/{1}/raw/master/f/{2}/{3}'.format( + PAGUREURL.rstrip('/'), BUGZILLA_OVERRIDE_REPO, project['namespace'], + project['name']) + + if VERBOSE: + print('Querying {0}'.format(pagure_override_url)) + override_rv = session.get(pagure_override_url, timeout=30) + if override_rv.status_code == 200: + override_yaml = yaml.load(override_rv.text) + return override_yaml.get('bugzilla_contact', {}) + return {} + + +@cache.cache_on_arguments() +def _get_package_summary_from_mdapi(namespace, repo, session=None): + summary = None + if namespace != 'rpms': + return summary + + if session is None: + session = retry_session() + + url = '{0}/rawhide/srcpkg/{1}'.format(MDAPIURL.rstrip('/'), repo) + if VERBOSE: + print('Querying {0}'.format(url)) + + rv = session.get(url, timeout=60) + if rv.ok: + rv_json = rv.json() + summary = rv_json['summary'] + elif not rv.ok and rv.status_code != 404: + error_msg = ('The connection to "{0}" failed with the status code {1} ' + 'and output "{2}"').format(url, rv.status_code, rv.text) + raise RuntimeError(error_msg) + + return summary + + +def _get_pdc_branches(session, repo): + """ + Gets the branches on a project. This function is used for mapping. + :param repo: the project dict + :return: a list of the repo's branches + """ + branches_url = '{0}component-branches/'.format(PDCURL) + params = dict( + global_component=repo['name'], + type=PDC_TYPES[repo['namespace']] + ) + if VERBOSE: + print('Querying {0} {1}'.format(branches_url, params)) + rv = session.get(branches_url, params=params, timeout=60) + + # If the project's branches can't be reported, just return no branches and + # it will be skipped later on + if not rv.ok: + print(('The connection to "{0}" failed with the status code {1} and ' + 'output "{2}"'.format(branches_url, rv.status_code, rv.text)), + file=sys.stderr) + return [] + + data = rv.json() + return [branch['name'] for branch in data['results']] + +def _is_retired(product, project): + branches = project['branches'] + if product == 'Fedora EPEL': + for branch, active in branches: + if re.match(r'^epel\d+$', branch): + if active: + return False + # No active branches means it is retired. + return True + else: + for branch, active in branches: + if active: + return False + return True + + +def _to_legacy_schema(product_and_project, session=None): + """ + This function translates the JSON of a Pagure project to what PkgDB used to + output in the Bugzilla API. This function is used for mapping. + :param project_and_product: a tuple containing the dictionary of the JSON + of a Pagure project and a string of the product (e.g. "Fedora", + "Fedora EPEL") + :param session: a requests session object or None + :return: a dictionary of the content that the PkgDB Bugzilla API would + return + """ + product, project = product_and_project + + if session is None: + session = retry_session() + + owner = project['poc'] + watchers = project['watchers'] + + summary = _get_package_summary_from_mdapi( + project['namespace'], project['name'], session) + + # Check if the project is retired in PDC, and if so set assignee to orphan. + if _is_retired(product, project): + owner = 'orphan' + + # Check if the Bugzilla ticket assignee has been manually overridden + override_yaml = _get_override_yaml(project) + if override_yaml.get(product) \ + and isinstance(override_yaml[product], string_types): + owner = override_yaml[product] + + return { + 'cclist': { + # Groups is empty because you can't have groups watch projects. + # This is done only at the user level. + 'groups': [], + 'people': watchers, + }, + 'owner': owner, + # No package has this set in PkgDB's API, so it can be safely turned + # off and set to the defaults later on in the code + 'qacontact': None, + 'summary': summary, + # These two values are not part of original PkgDB RV, but they are + # useful + 'product': product, + 'project': project['name'] + } + + +if __name__ == '__main__': + sys.stdout = codecs.getwriter('utf-8')(sys.stdout) + + parser = argparse.ArgumentParser( + description='Script syncing information between Pagure and bugzilla' + ) + parser.add_argument( + '--dry-run', dest='dryrun', action='store_true', default=False, + help='Do not actually make the changes') + parser.add_argument( + '--verbose', dest='verbose', action='store_true', default=False, + help='Print actions verbosely') + parser.add_argument( + '--debug', dest='debug', action='store_true', default=False, + help='Combination of --verbose and --dry-run') + + args = parser.parse_args() + + if args.debug: + VERBOSE = True + DRYRUN = True + + if args.verbose: + VERBOSE = True + + if args.dryrun: + DRYRUN = True + + # Non-fatal errors to alert people about + errors = [] + + projects_dict = { + 'Fedora': {}, + 'Fedora Container': {}, + 'Fedora Modules': {}, + 'Fedora EPEL': {}, + } + + session = retry_session() + pool = multiprocessing.pool.ThreadPool(8) + + # Get the initial ownership and CC data from pagure + # This part is easy. + poc_url = PAGURE_DIST_GIT_URL + '/extras/pagure_poc.json' + if VERBOSE: + print("Querying %r for points of contact." % poc_url) + pagure_namespace_to_poc = session.get(poc_url, timeout=120).json() + cc_url = PAGURE_DIST_GIT_URL + '/extras/pagure_bz.json' + if VERBOSE: + print("Querying %r for initial cc list." % cc_url) + pagure_namespace_to_cc = session.get(cc_url, timeout=120).json() + + # Combine and collapse those two into a single list: + pagure_projects = [] + for namespace, entries in pagure_namespace_to_poc.items(): + for name, poc in entries.items(): + pagure_projects.append(dict( + namespace=namespace, + name=name, + poc=poc, + watchers=pagure_namespace_to_cc[namespace][name], + )) +{% if env == 'staging' %} + # Filter out any modules, since we don't have rights to create new + # components in the "Fedora Modules" project yet + pagure_projects = [ + p for p in pagure_projects if p['namespace'] != 'modules' + ] +{% endif %} + + branches_url = PDCURL.split('rest_api')[0] + 'extras/active_branches.json' + if VERBOSE: + print("Querying %r for EOL information." % branches_url) + pdc_branches = session.get(branches_url, timeout=120).json() + for proj in pagure_projects: + if proj['namespace'] not in PDC_TYPES: + proj['branches'] = [] + if VERBOSE: + print('! Namespace {namespace} unknown to PDC, project ' + '{namespace}/{name} ignored'.format(**proj)) + continue + pdc_type = PDC_TYPES[proj['namespace']] + proj['branches'] = pdc_branches.get(pdc_type, {}).get(proj['name'], []) + if not proj['branches'] and VERBOSE: + print("! No PDC branch found for {namespace}/{name}".format(**proj)) + + # Determine what products each project maps to based on its branches. + # pagure_rpms_project_products will be in the format of + # [('python-requests': 'Fedora')...] which will be used my a mapping + # function below + for project in pagure_projects: + products = set() + for branch, active in project['branches']: + if re.match(r'^epel\d+$', branch): + products.add('Fedora EPEL') + else: + products.add(NAMESPACE_TO_PRODUCT[project['namespace']]) + project['products'] = list(products) + + ## Now, we must transform the data we collected into something that PkgDB + ## would have returned + p_to_legacy_schema = resilient_partial(_to_legacy_schema, session=session) + items = [ + (product, project) + for project in pagure_projects + for product in project['products'] + ] + legacy_responses = pool.map(p_to_legacy_schema, items) + for response in legacy_responses: + if not response: + continue + projects_dict[response['product']][response['project']] = response + + # Initialize the connection to bugzilla + bugzilla = BugzillaProxy(BZSERVER, BZUSER, BZPASS, projects_dict) + + for product in projects_dict.keys(): + if product not in PRODUCTS: + continue + for pkg in sorted(projects_dict[product]): + if VERBOSE: + print("Assesssing bugzilla status for %r" % pkg) + pkgInfo = projects_dict[product][pkg] + try: + bugzilla.add_edit_component( + pkg, + product, + pkgInfo['owner'], + pkgInfo['summary'], + pkgInfo['qacontact'], + pkgInfo['cclist'] + ) + except ValueError as e: + # A username didn't have a bugzilla address + errors.append(str(e.args)) + except DataChangedError as e: + # A Package or Collection was returned via xmlrpc but wasn't + # present when we tried to change it + errors.append(str(e.args)) + except xmlrpclib.ProtocolError as e: + # Unrecoverable and likely means that nothing is going to + # succeed. + errors.append(str(e.args)) + break + except xmlrpclib.Error as e: + # An error occurred in the xmlrpc call. Shouldn't happen but + # we better see what it is + errors.append('%s -- %s' % (pkg, e.args[-1])) + + # Send notification of errors + if errors: + if VERBOSE: + print('[DEBUG]', '\n'.join(errors)) + else: + notify_users(errors) + send_email( + EMAIL_FROM, + NOTIFYEMAIL, + 'Errors while syncing bugzilla with the PackageDB', + TMPL_EMAIL_ADMIN % ('\n'.join(errors),)) + else: + with open(DATA_CACHE, 'w') as stream: + json.dump({}, stream) + + sys.exit(0)