ciabot/bugzilla/__init__.py | 133 +++ ciabot/bugzilla/base.py | 1762 ++++++++++++++++++++++++++++++++++++++++++ ciabot/bugzilla/bug.py | 517 ++++++++++++ ciabot/bugzilla/bugzilla3.py | 34 ciabot/bugzilla/bugzilla4.py | 47 + ciabot/bugzilla/rhbugzilla.py | 368 ++++++++ 6 files changed, 2861 insertions(+)
New commits: commit 1a3ba69f8baa627412d86d8a7ef944a005e1385c Author: Miklos Vajna <[email protected]> Date: Wed Oct 7 14:10:40 2015 +0200 ciabot: bundle special bugzilla python bindings At least it's something different compared to what I have in my python-bugzilla package on openSUSE, and seems this was installed in /usr/local on the production instance so far. Change-Id: Ic485140ee7d6d19107deca6507f695fa6737f465 diff --git a/ciabot/bugzilla/__init__.py b/ciabot/bugzilla/__init__.py new file mode 100644 index 0000000..382db52 --- /dev/null +++ b/ciabot/bugzilla/__init__.py @@ -0,0 +1,133 @@ +# python-bugzilla - a Python interface to bugzilla using xmlrpclib. +# +# Copyright (C) 2007, 2008 Red Hat Inc. +# Author: Will Woods <[email protected]> +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. See http://www.gnu.org/copyleft/gpl.html for +# the full text of the license. + +__version__ = "1.1.0" +version = __version__ + +import sys +from logging import getLogger + +if hasattr(sys.version_info, "major") and sys.version_info.major >= 3: + # pylint: disable=F0401 + from xmlrpc.client import Fault, ServerProxy +else: + from xmlrpclib import Fault, ServerProxy + +log = getLogger("bugzilla") + + +from bugzilla.base import BugzillaBase as _BugzillaBase +from bugzilla.base import BugzillaError +from bugzilla.base import RequestsTransport as _RequestsTransport +from bugzilla.bugzilla3 import Bugzilla3, Bugzilla32, Bugzilla34, Bugzilla36 +from bugzilla.bugzilla4 import Bugzilla4, Bugzilla42, Bugzilla44 +from bugzilla.rhbugzilla import RHBugzilla, RHBugzilla3, RHBugzilla4 + + +# Back compat for deleted NovellBugzilla +class NovellBugzilla(Bugzilla34): + pass + + +def _getBugzillaClassForURL(url, sslverify): + url = Bugzilla3.fix_url(url) + log.debug("Detecting subclass for %s", url) + s = ServerProxy(url, _RequestsTransport(url, sslverify=sslverify)) + rhbz = False + bzversion = '' + c = None + + if "bugzilla.redhat.com" in url: + log.info("Using RHBugzilla for URL containing bugzilla.redhat.com") + return RHBugzilla + if "bugzilla.novell.com" in url: + log.info("Using NovellBugzilla for URL containing novell.com") + return NovellBugzilla + + # Check for a Red Hat extension + try: + log.debug("Checking for Red Hat Bugzilla extension") + extensions = s.Bugzilla.extensions() + if extensions.get('extensions', {}).get('RedHat', False): + rhbz = True + except Fault: + pass + log.debug("rhbz=%s", str(rhbz)) + + # Try to get the bugzilla version string + try: + log.debug("Checking return value of Bugzilla.version()") + r = s.Bugzilla.version() + bzversion = r['version'] + except Fault: + pass + log.debug("bzversion='%s'", str(bzversion)) + + # note preference order: RHBugzilla* wins if available + if rhbz: + c = RHBugzilla + elif bzversion.startswith("4."): + if bzversion.startswith("4.0"): + c = Bugzilla4 + elif bzversion.startswith("4.2"): + c = Bugzilla42 + else: + log.debug("No explicit match for %s, using latest bz4", bzversion) + c = Bugzilla44 + else: + if bzversion.startswith('3.6'): + c = Bugzilla36 + elif bzversion.startswith('3.4'): + c = Bugzilla34 + elif bzversion.startswith('3.2'): + c = Bugzilla32 + else: + log.debug("No explicit match for %s, fall through", bzversion) + c = Bugzilla3 + + return c + + +class Bugzilla(_BugzillaBase): + ''' + Magical Bugzilla class that figures out which Bugzilla implementation + to use and uses that. + ''' + def _init_class_from_url(self, url, sslverify): + if url is None: + raise TypeError("You must pass a valid bugzilla URL") + + c = _getBugzillaClassForURL(url, sslverify) + if not c: + raise ValueError("Couldn't determine Bugzilla version for %s" % + url) + + self.__class__ = c + log.info("Chose subclass %s v%s", c.__name__, c.version) + return True + + +# This is the list of possible Bugzilla instances an app can use, +# bin/bugzilla uses it for the --bztype field +classlist = [ + "Bugzilla3", "Bugzilla32", "Bugzilla34", "Bugzilla36", + "Bugzilla4", "Bugzilla42", "Bugzilla44", + "RHBugzilla3", "RHBugzilla4", "RHBugzilla", + "NovellBugzilla", +] + +# This is the public API. If you are explicitly instantiating any other +# class, using some function, or poking into internal files, don't complain +# if things break on you. +__all__ = classlist + [ + 'BugzillaError', + 'Bugzilla', +] diff --git a/ciabot/bugzilla/base.py b/ciabot/bugzilla/base.py new file mode 100644 index 0000000..d9b4e28 --- /dev/null +++ b/ciabot/bugzilla/base.py @@ -0,0 +1,1762 @@ +# base.py - the base classes etc. for a Python interface to bugzilla +# +# Copyright (C) 2007, 2008, 2009, 2010 Red Hat Inc. +# Author: Will Woods <[email protected]> +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. See http://www.gnu.org/copyleft/gpl.html for +# the full text of the license. + +import locale +import os +import sys + +from io import BytesIO + +if hasattr(sys.version_info, "major") and sys.version_info.major >= 3: + # pylint: disable=F0401,E0611 + from configparser import SafeConfigParser + from http.cookiejar import LoadError, LWPCookieJar, MozillaCookieJar + from urllib.parse import urlparse, parse_qsl + from xmlrpc.client import ( + Binary, Fault, ProtocolError, ServerProxy, Transport) +else: + from ConfigParser import SafeConfigParser + from cookielib import LoadError, LWPCookieJar, MozillaCookieJar + from urlparse import urlparse, parse_qsl + from xmlrpclib import ( + Binary, Fault, ProtocolError, ServerProxy, Transport) + +import requests + +from bugzilla import __version__, log +from bugzilla.bug import _Bug, _User + + +# Backwards compatibility +Bug = _Bug + +mimemagic = None + + +def _detect_filetype(fname): + # pylint: disable=E1103 + # E1103: Instance of 'bool' has no '%s' member + # pylint confuses mimemagic to be of type 'bool' + global mimemagic + + if mimemagic is None: + try: + # pylint: disable=F0401 + # F0401: Unable to import 'magic' (import-error) + import magic + mimemagic = magic.open(getattr(magic, "MAGIC_MIME_TYPE", 16)) + mimemagic.load() + except ImportError: + e = sys.exc_info()[1] + log.debug("Could not load python-magic: %s", e) + mimemagic = False + if mimemagic is False: + return None + + if not os.path.isabs(fname): + return None + + try: + return mimemagic.file(fname) + except Exception: + e = sys.exc_info()[1] + log.debug("Could not detect content_type: %s", e) + return None + + +def _build_cookiejar(cookiefile): + cj = MozillaCookieJar(cookiefile) + if cookiefile is None: + return cj + if not os.path.exists(cookiefile): + # Make sure a new file has correct permissions + open(cookiefile, 'a').close() + os.chmod(cookiefile, 0o600) + cj.save() + return cj + + # We always want to use Mozilla cookies, but we previously accepted + # LWP cookies. If we see the latter, convert it to former + try: + cj.load() + return cj + except LoadError: + pass + + try: + cj = LWPCookieJar(cookiefile) + cj.load() + except LoadError: + raise BugzillaError("cookiefile=%s not in LWP or Mozilla format" % + cookiefile) + + retcj = MozillaCookieJar(cookiefile) + for cookie in cj: + retcj.set_cookie(cookie) + retcj.save() + return retcj + + +class _BugzillaToken(object): + def __init__(self, uri, tokenfilename): + self.tokenfilename = tokenfilename + self.tokenfile = SafeConfigParser() + self.domain = urlparse(uri)[1] + + if self.tokenfilename: + self.tokenfile.read(self.tokenfilename) + + if self.domain not in self.tokenfile.sections(): + self.tokenfile.add_section(self.domain) + + @property + def value(self): + if self.tokenfile.has_option(self.domain, 'token'): + return self.tokenfile.get(self.domain, 'token') + else: + return None + + @value.setter + def value(self, value): + if self.value == value: + return + + if value is None: + self.tokenfile.remove_option(self.domain, 'token') + else: + self.tokenfile.set(self.domain, 'token', value) + + if self.tokenfilename: + with open(self.tokenfilename, 'w') as tokenfile: + log.debug("Saving to tokenfile") + self.tokenfile.write(tokenfile) + + def __repr__(self): + return '<Bugzilla Token :: %s>' % (self.value) + + +class _BugzillaServerProxy(ServerProxy): + def __init__(self, uri, tokenfile, *args, **kwargs): + # pylint: disable=super-init-not-called + # No idea why pylint complains here, must be a bug + ServerProxy.__init__(self, uri, *args, **kwargs) + self.token = _BugzillaToken(uri, tokenfile) + + def clear_token(self): + self.token.value = None + + def _ServerProxy__request(self, methodname, params): + if self.token.value is not None: + if len(params) == 0: + params = ({}, ) + + if 'Bugzilla_token' not in params[0]: + params[0]['Bugzilla_token'] = self.token.value + + # pylint: disable=maybe-no-member + ret = ServerProxy._ServerProxy__request(self, methodname, params) + # pylint: enable=maybe-no-member + + if isinstance(ret, dict) and 'token' in ret.keys(): + self.token.value = ret.get('token') + return ret + + +class RequestsTransport(Transport): + user_agent = 'Python/Bugzilla' + + def __init__(self, url, cookiejar=None, + sslverify=True, sslcafile=None, debug=0): + # pylint: disable=W0231 + # pylint does not handle multiple import of Transport well + if hasattr(Transport, "__init__"): + Transport.__init__(self, use_datetime=False) + + self.verbose = debug + self._cookiejar = cookiejar + + # transport constructor needs full url too, as xmlrpc does not pass + # scheme to request + self.scheme = urlparse(url)[0] + if self.scheme not in ["http", "https"]: + raise Exception("Invalid URL scheme: %s (%s)" % (self.scheme, url)) + + self.use_https = self.scheme == 'https' + + self.request_defaults = { + 'cert': sslcafile if self.use_https else None, + 'cookies': cookiejar, + 'verify': sslverify, + 'headers': { + 'Content-Type': 'text/xml', + 'User-Agent': self.user_agent, + } + } + + def parse_response(self, response): + """ Parse XMLRPC response """ + parser, unmarshaller = self.getparser() + parser.feed(response.text.encode('utf-8')) + parser.close() + return unmarshaller.close() + + def _request_helper(self, url, request_body): + """ + A helper method to assist in making a request and provide a parsed + response. + """ + response = None + try: + response = requests.post( + url, data=request_body, **self.request_defaults) + + # We expect utf-8 from the server + response.encoding = 'UTF-8' + + # update/set any cookies + if self._cookiejar is not None: + for cookie in response.cookies: + self._cookiejar.set_cookie(cookie) + + if self._cookiejar.filename is not None: + # Save is required only if we have a filename + self._cookiejar.save() + + response.raise_for_status() + return self.parse_response(response) + except requests.RequestException: + e = sys.exc_info()[1] + if not response: + raise + raise ProtocolError( + url, response.status_code, str(e), response.headers) + except Fault: + raise sys.exc_info()[1] + except Exception: + # pylint: disable=W0201 + e = BugzillaError(str(sys.exc_info()[1])) + e.__traceback__ = sys.exc_info()[2] + raise e + + def request(self, host, handler, request_body, verbose=0): + self.verbose = verbose + url = "%s://%s%s" % (self.scheme, host, handler) + + # xmlrpclib fails to escape \r + request_body = request_body.replace(b'\r', b'
') + + # Needed for python-requests < 2.0 with python3, otherwise we get + # Content-Type error later for the POST request + request_body = request_body.decode('utf-8') + + return self._request_helper(url, request_body) + + +class BugzillaError(Exception): + '''Error raised in the Bugzilla client code.''' + pass + + +class _FieldAlias(object): + """ + Track API attribute names that differ from what we expose in users. + + For example, originally 'short_desc' was the name of the property that + maps to 'summary' on modern bugzilla. We want pre-existing API users + to be able to continue to use Bug.short_desc, and + query({"short_desc": "foo"}). This class tracks that mapping. + + @oldname: The old attribute name + @newname: The modern attribute name + @is_api: If True, use this mapping for values sent to the xmlrpc API + (like the query example) + @is_bug: If True, use this mapping for Bug attribute names. + """ + def __init__(self, newname, oldname, is_api=True, is_bug=True): + self.newname = newname + self.oldname = oldname + self.is_api = is_api + self.is_bug = is_bug + + +class BugzillaBase(object): + '''An object which represents the data and methods exported by a Bugzilla + instance. Uses xmlrpclib to do its thing. You'll want to create one thusly: + bz=Bugzilla(url='https://bugzilla.redhat.com/xmlrpc.cgi', + user=u, password=p) + + You can get authentication cookies by calling the login() method. These + cookies will be stored in a MozillaCookieJar-style file specified by the + 'cookiefile' attribute (which defaults to ~/.bugzillacookies). Once you + get cookies this way, you will be considered logged in until the cookie + expires. + + You may also specify 'user' and 'password' in a bugzillarc file, either + /etc/bugzillarc or ~/.bugzillarc. The latter will override the former. + The format works like this: + [bugzilla.yoursite.com] + user = username + password = password + You can also use the [DEFAULT] section to set defaults that apply to + any site without a specific section of its own. + Be sure to set appropriate permissions on bugzillarc if you choose to + store your password in it! + + This is an abstract class; it must be implemented by a concrete subclass + which actually connects the methods provided here to the appropriate + methods on the bugzilla instance. + + :kwarg url: base url for the bugzilla instance + :kwarg user: usename to connect with + :kwarg password: password for the connecting user + :kwarg cookiefile: Location to save the session cookies so you don't have + to keep giving the library your username and password. This defaults + to ~/.bugzillacookies. If set to None, the library won't save the + cookies persistently. + ''' + + # bugzilla version that the class is targetting. filled in by + # subclasses + bz_ver_major = 0 + bz_ver_minor = 0 + + # Intended to be the API version of the class, but historically is + # unused and basically worthless since we don't plan on breaking API. + version = "0.1" + + @staticmethod + def url_to_query(url): + ''' + Given a big huge bugzilla query URL, returns a query dict that can + be passed along to the Bugzilla.query() method. + ''' + q = {} + + # pylint: disable=unpacking-non-sequence + (ignore, ignore, path, + ignore, query, ignore) = urlparse(url) + + base = os.path.basename(path) + if base not in ('buglist.cgi', 'query.cgi'): + return {} + + for (k, v) in parse_qsl(query): + if k not in q: + q[k] = v + elif isinstance(q[k], list): + q[k].append(v) + else: + oldv = q[k] + q[k] = [oldv, v] + + # Handle saved searches + if base == "buglist.cgi" and "namedcmd" in q and "sharer_id" in q: + q = { + "sharer_id": q["sharer_id"], + "savedsearch": q["namedcmd"], + } + + return q + + @staticmethod + def fix_url(url): + """ + Turn passed url into a bugzilla XMLRPC web url + """ + if '://' not in url: + log.debug('No scheme given for url, assuming https') + url = 'https://' + url + if url.count('/') < 3: + log.debug('No path given for url, assuming /xmlrpc.cgi') + url = url + '/xmlrpc.cgi' + return url + + def __init__(self, url=None, user=None, password=None, cookiefile=-1, + sslverify=True, tokenfile=-1): + # Hook to allow Bugzilla autodetection without weirdly overriding + # __init__ + if self._init_class_from_url(url, sslverify): + kwargs = locals().copy() + del(kwargs["self"]) + + # pylint: disable=non-parent-init-called + self.__class__.__init__(self, **kwargs) + return + + # Settings the user might want to tweak + self.user = user or '' + self.password = password or '' + self.url = '' + + self._transport = None + self._cookiejar = None + self._sslverify = bool(sslverify) + + self.logged_in = False + self.bug_autorefresh = True + + # Bugzilla object state info that users shouldn't mess with + self._proxy = None + self._products = None + self._bugfields = None + self._components = {} + self._components_details = {} + self._init_private_data() + + if cookiefile == -1: + cookiefile = os.path.expanduser('~/.bugzillacookies') + if tokenfile == -1: + tokenfile = os.path.expanduser("~/.bugzillatoken") + log.debug("Using tokenfile=%s", tokenfile) + self.cookiefile = cookiefile + self.tokenfile = tokenfile + + # List of field aliases. Maps old style RHBZ parameter + # names to actual upstream values. Used for createbug() and + # query include_fields at least. + self._field_aliases = [] + self._add_field_alias('summary', 'short_desc') + self._add_field_alias('description', 'comment') + self._add_field_alias('platform', 'rep_platform') + self._add_field_alias('severity', 'bug_severity') + self._add_field_alias('status', 'bug_status') + self._add_field_alias('id', 'bug_id') + self._add_field_alias('blocks', 'blockedby') + self._add_field_alias('blocks', 'blocked') + self._add_field_alias('depends_on', 'dependson') + self._add_field_alias('creator', 'reporter') + self._add_field_alias('url', 'bug_file_loc') + self._add_field_alias('dupe_of', 'dupe_id') + self._add_field_alias('dupe_of', 'dup_id') + self._add_field_alias('comments', 'longdescs') + self._add_field_alias('creation_time', 'opendate') + self._add_field_alias('creation_time', 'creation_ts') + self._add_field_alias('whiteboard', 'status_whiteboard') + self._add_field_alias('last_change_time', 'delta_ts') + + if url: + self.connect(url) + + def _init_class_from_url(self, url, sslverify): + ignore = url + ignore = sslverify + + def _init_private_data(self): + '''initialize private variables used by this bugzilla instance.''' + self._proxy = None + self._products = None + self._bugfields = None + self._components = {} + self._components_details = {} + + def _get_user_agent(self): + ret = ('Python-urllib bugzilla.py/%s %s' % + (__version__, str(self.__class__.__name__))) + return ret + user_agent = property(_get_user_agent) + + + ################### + # Private helpers # + ################### + + def _check_version(self, major, minor): + """ + Check if the detected bugzilla version is >= passed major/minor pair. + """ + if major < self.bz_ver_major: + return True + if (major == self.bz_ver_major and minor <= self.bz_ver_minor): + return True + return False + + def _listify(self, val): + if val is None: + return val + if type(val) is list: + return val + return [val] + + def _product_id_to_name(self, productid): + '''Convert a product ID (int) to a product name (str).''' + for p in self.products: + if p['id'] == productid: + return p['name'] + raise ValueError('No product with id #%i' % productid) + + def _product_name_to_id(self, product): + '''Convert a product name (str) to a product ID (int).''' + for p in self.products: + if p['name'] == product: + return p['id'] + raise ValueError('No product named "%s"' % product) + + def _add_field_alias(self, *args, **kwargs): + self._field_aliases.append(_FieldAlias(*args, **kwargs)) + + def _get_bug_aliases(self): + return [(f.newname, f.oldname) + for f in self._field_aliases if f.is_bug] + + def _get_api_aliases(self): + return [(f.newname, f.oldname) + for f in self._field_aliases if f.is_api] + + + ################### + # Cookie handling # + ################### + + def _getcookiefile(self): + '''cookiefile is the file that bugzilla session cookies are loaded + and saved from. + ''' + return self._cookiejar.filename + + def _delcookiefile(self): + self._cookiejar = None + + def _setcookiefile(self, cookiefile): + if (self._cookiejar and cookiefile == self._cookiejar.filename): + return + + if self._proxy is not None: + raise RuntimeError("Can't set cookies with an open connection, " + "disconnect() first.") + + log.debug("Using cookiefile=%s", cookiefile) + self._cookiejar = _build_cookiejar(cookiefile) + + cookiefile = property(_getcookiefile, _setcookiefile, _delcookiefile) + + + ############################# + # Login/connection handling # + ############################# + + configpath = ['/etc/bugzillarc', '~/.bugzillarc'] + + def readconfig(self, configpath=None): + ''' + Read bugzillarc file(s) into memory. + ''' + if not configpath: + configpath = self.configpath + configpath = [os.path.expanduser(p) for p in configpath] + c = SafeConfigParser() + r = c.read(configpath) + if not r: + return + # See if we have a config section that matches this url. + section = "" + # Substring match - prefer the longest match found + log.debug("Searching for config section matching %s", self.url) + for s in sorted(c.sections()): + if s in self.url: + log.debug("Found matching section: %s", s) + section = s + if not section: + return + for k, v in c.items(section): + if k in ('user', 'password'): + log.debug("Setting '%s' from configfile", k) + setattr(self, k, v) + + def connect(self, url=None): + ''' + Connect to the bugzilla instance with the given url. + + This will also read any available config files (see readconfig()), + which may set 'user' and 'password'. + + If 'user' and 'password' are both set, we'll run login(). Otherwise + you'll have to login() yourself before some methods will work. + ''' + if url is None and self.url: + url = self.url + url = self.fix_url(url) + + self._transport = RequestsTransport( + url, self._cookiejar, sslverify=self._sslverify) + self._transport.user_agent = self.user_agent + self._proxy = _BugzillaServerProxy(url, self.tokenfile, + self._transport) + + self.url = url + # we've changed URLs - reload config + self.readconfig() + + if (self.user and self.password): + log.info("user and password present - doing login()") + self.login() + + def disconnect(self): + ''' + Disconnect from the given bugzilla instance. + ''' + # clears all the connection state + self._init_private_data() + + + def _login(self, user, password): + '''Backend login method for Bugzilla3''' + return self._proxy.User.login({'login': user, 'password': password}) + + def _logout(self): + '''Backend login method for Bugzilla3''' + return self._proxy.User.logout() + + def login(self, user=None, password=None): + '''Attempt to log in using the given username and password. Subsequent + method calls will use this username and password. Returns False if + login fails, otherwise returns some kind of login info - typically + either a numeric userid, or a dict of user info. It also sets the + logged_in attribute to True, if successful. + + If user is not set, the value of Bugzilla.user will be used. If *that* + is not set, ValueError will be raised. If login fails, BugzillaError + will be raised. + + This method will be called implicitly at the end of connect() if user + and password are both set. So under most circumstances you won't need + to call this yourself. + ''' + if user: + self.user = user + if password: + self.password = password + + if not self.user: + raise ValueError("missing username") + if not self.password: + raise ValueError("missing password") + + try: + ret = self._login(self.user, self.password) + self.logged_in = True + self.password = '' + log.info("login successful for user=%s", self.user) + return ret + except Fault: + e = sys.exc_info()[1] + raise BugzillaError("Login failed: %s" % str(e.faultString)) + + def logout(self): + '''Log out of bugzilla. Drops server connection and user info, and + destroys authentication cookies.''' + self._logout() + self.disconnect() + self.user = '' + self.password = '' + self.logged_in = False + + + ############################################# + # Fetching info about the bugzilla instance # + ############################################# + + def _getbugfields(self): + raise RuntimeError("This bugzilla version does not support listing " + "bug fields.") + + def getbugfields(self, force_refresh=False): + ''' + Calls getBugFields, which returns a list of fields in each bug + for this bugzilla instance. This can be used to set the list of attrs + on the Bug object. + ''' + if force_refresh or self._bugfields is None: + log.debug("Refreshing bugfields") + self._bugfields = self._getbugfields() + self._bugfields.sort() + log.debug("bugfields = %s", self._bugfields) + + return self._bugfields + bugfields = property(fget=lambda self: self.getbugfields(), + fdel=lambda self: setattr(self, '_bugfields', None)) + + + def refresh_products(self, **kwargs): + """ + Refresh a product's cached info + Takes same arguments as _getproductinfo + """ + if self._products is None: + self._products = [] + + for product in self._getproductinfo(**kwargs): + added = False + for current in self._products[:]: + if (current.get("id", -1) != product.get("id", -2) and + current.get("name", -1) != product.get("name", -2)): + continue + + self._products.remove(current) + self._products.append(product) + added = True + break + if not added: + self._products.append(product) + + def getproducts(self, force_refresh=False, **kwargs): + '''Get product data: names, descriptions, etc. + The data varies between Bugzilla versions but the basic format is a + list of dicts, where the dicts will have at least the following keys: + {'id':1, 'name':"Some Product", 'description':"This is a product"} + + Any method that requires a 'product' can be given either the + id or the name.''' + if force_refresh or not self._products: + self._products = self._getproducts(**kwargs) + return self._products + + products = property(fget=lambda self: self.getproducts(), + fdel=lambda self: setattr(self, '_products', None)) + + + def getcomponentsdetails(self, product, force_refresh=False): + '''Returns a dict of dicts, containing detailed component information + for the given product. The keys of the dict are component names. For + each component, the value is a dict with the following keys: + description, initialowner, initialqacontact''' + if force_refresh or product not in self._components_details: + clist = self._getcomponentsdetails(product) + cdict = {} + for item in clist: + name = item['component'] + del item['component'] + cdict[name] = item + self._components_details[product] = cdict + + return self._components_details[product] + + def getcomponentdetails(self, product, component, force_refresh=False): + '''Get details for a single component. Returns a dict with the + following keys: + description, initialowner, initialqacontact, initialcclist''' + d = self.getcomponentsdetails(product, force_refresh) + return d[component] + + def getcomponents(self, product, force_refresh=False): + '''Return a dict of components:descriptions for the given product.''' + if force_refresh or product not in self._components: + self._components[product] = self._getcomponents(product) + return self._components[product] + + def _component_data_convert(self, data, update=False): + if type(data['product']) is int: + data['product'] = self._product_id_to_name(data['product']) + + + # Back compat for the old RH interface + convert_fields = [ + ("initialowner", "default_assignee"), + ("initialqacontact", "default_qa_contact"), + ("initialcclist", "default_cc"), + ] + for old, new in convert_fields: + if old in data: + data[new] = data.pop(old) + + if update: + names = {"product": data.pop("product"), + "component": data.pop("component")} + updates = {} + for k in data.keys(): + updates[k] = data.pop(k) + + data["names"] = [names] + data["updates"] = updates + + + def addcomponent(self, data): + ''' + A method to create a component in Bugzilla. Takes a dict, with the + following elements: + + product: The product to create the component in + component: The name of the component to create + desription: A one sentence summary of the component + default_assignee: The bugzilla login (email address) of the initial + owner of the component + default_qa_contact (optional): The bugzilla login of the + initial QA contact + default_cc: (optional) The initial list of users to be CC'ed on + new bugs for the component. + ''' + data = data.copy() + self._component_data_convert(data) + log.debug("Calling Component.create with: %s", data) + return self._proxy.Component.create(data) + + def editcomponent(self, data): + ''' + A method to edit a component in Bugzilla. Takes a dict, with + mandatory elements of product. component, and initialowner. + All other elements are optional and use the same names as the + addcomponent() method. + ''' + data = data.copy() + self._component_data_convert(data, update=True) + log.debug("Calling Component.update with: %s", data) + return self._proxy.Component.update(data) + + + def _getproductinfo(self, ids=None, names=None, + include_fields=None, exclude_fields=None): + ''' + Get all info for the requested products. + + @ids: List of product IDs to lookup + @names: List of product names to lookup (since bz 4.2, + though we emulate it for older versions) + @include_fields: Only include these fields in the output (since bz 4.2) + @exclude_fields: Do not include these fields in the output (since + bz 4.2) + ''' + if ids is None and names is None: + raise RuntimeError("Products must be specified") + + kwargs = {} + if not self._check_version(4, 2): + if names: + ids = [self._product_name_to_id(name) for name in names] + names = None + include_fields = None + exclude_fields = None + + if ids: + kwargs["ids"] = self._listify(ids) + if names: + kwargs["names"] = self._listify(names) + if include_fields: + kwargs["include_fields"] = include_fields + if exclude_fields: + kwargs["exclude_fields"] = exclude_fields + + # The bugzilla4 name is Product.get(), but Bugzilla3 only had + # Product.get_product, and bz4 kept an alias. + log.debug("Calling Product.get_products with: %s", kwargs) + ret = self._proxy.Product.get_products(kwargs) + return ret['products'] + + def _getproducts(self, **kwargs): + product_ids = self._proxy.Product.get_accessible_products() + r = self._getproductinfo(product_ids['ids'], **kwargs) + return r + + def _getcomponents(self, product): + if type(product) == str: + product = self._product_name_to_id(product) + r = self._proxy.Bug.legal_values({'product_id': product, + 'field': 'component'}) + return r['values'] + + def _getcomponentsdetails(self, product): + # Originally this was a RH extension getProdCompDetails + # Upstream support has been available since 4.2 + if not self._check_version(4, 2): + raise RuntimeError("This bugzilla version does not support " + "fetching component details.") + + comps = None + if self._products is None: + self._products = [] + + def _find_comps(): + for p in self._products: + if p["name"] != product: + continue + return p.get("components", None) + + comps = _find_comps() + if comps is None: + self.refresh_products(names=[product], + include_fields=["name", "id", "components"]) + comps = _find_comps() + + if comps is None: + raise ValueError("Unknown product '%s'" % product) + + # Convert to old style dictionary to maintain back compat + # with original RH bugzilla call + ret = [] + for comp in comps: + row = {} + row["component"] = comp["name"] + row["initialqacontact"] = comp["default_qa_contact"] + row["initialowner"] = comp["default_assigned_to"] + row["description"] = comp["description"] + ret.append(row) + return ret + + + ################### + # getbug* methods # + ################### + + # getbug_extra_fields: Extra fields that need to be explicitly + # requested from Bug.get in order for the data to be returned. This + # decides the difference between getbug() and getbugsimple(). + # + # As of Dec 2012 it seems like only RH bugzilla actually has behavior + # like this, for upstream bz it returns all info for every Bug.get() + _getbug_extra_fields = [] + _supports_getbug_extra_fields = False + + def _getbugs(self, idlist, simple=False, permissive=True, + include_fields=None, exclude_fields=None, extra_fields=None): + ''' + Return a list of dicts of full bug info for each given bug id. + bug ids that couldn't be found will return None instead of a dict. + + @simple: If True, don't ask for any large extra_fields. + ''' + oldidlist = idlist + idlist = [] + for i in oldidlist: + try: + idlist.append(int(i)) + except ValueError: + # String aliases can be passed as well + idlist.append(i) + + extra_fields = self._listify(extra_fields or []) + if not simple: + extra_fields += self._getbug_extra_fields + + getbugdata = {"ids": idlist} + if permissive: + getbugdata["permissive"] = 1 + if self.bz_ver_major >= 4: + if include_fields: + getbugdata["include_fields"] = self._listify(include_fields) + if exclude_fields: + getbugdata["exclude_fields"] = self._listify(exclude_fields) + if self._supports_getbug_extra_fields: + getbugdata["extra_fields"] = extra_fields + + log.debug("Calling Bug.get_bugs with: %s", getbugdata) + r = self._proxy.Bug.get_bugs(getbugdata) + + if self.bz_ver_major >= 4: + bugdict = dict([(b['id'], b) for b in r['bugs']]) + else: + bugdict = dict([(b['id'], b['internals']) for b in r['bugs']]) + + ret = [] + for i in idlist: + found = None + if i in bugdict: + found = bugdict[i] + else: + # Need to map an alias + for valdict in bugdict.values(): + if i in valdict.get("alias", []): + found = valdict + break + + ret.append(found) + + return ret + + def _getbug(self, objid, simple=False, + include_fields=None, exclude_fields=None, extra_fields=None): + '''Return a dict of full bug info for the given bug id''' + return self._getbugs([objid], simple=simple, permissive=False, + include_fields=include_fields, exclude_fields=exclude_fields, + extra_fields=extra_fields)[0] + + def getbug(self, objid, + include_fields=None, exclude_fields=None, extra_fields=None): + '''Return a Bug object with the full complement of bug data + already loaded.''' + data = self._getbug(objid, include_fields=include_fields, + exclude_fields=exclude_fields, extra_fields=extra_fields) + return _Bug(self, dict=data, autorefresh=self.bug_autorefresh) + + def getbugs(self, idlist, + include_fields=None, exclude_fields=None, extra_fields=None): + '''Return a list of Bug objects with the full complement of bug data + already loaded. If there's a problem getting the data for a given id, + the corresponding item in the returned list will be None.''' + data = self._getbugs(idlist, include_fields=include_fields, + exclude_fields=exclude_fields, extra_fields=extra_fields) + return [(b and _Bug(self, dict=b, + autorefresh=self.bug_autorefresh)) or None + for b in data] + + # Since for so long getbugsimple was just getbug, I don't think we can + # remove any fields without possibly causing a slowdown for some + # existing users. Just have this API mean 'don't ask for the extra + # big stuff' + def getbugsimple(self, objid): + '''Return a Bug object given bug id, populated with simple info''' + return _Bug(self, + dict=self._getbug(objid, simple=True), + autorefresh=self.bug_autorefresh) + + def getbugssimple(self, idlist): + '''Return a list of Bug objects for the given bug ids, populated with + simple info. As with getbugs(), if there's a problem getting the data + for a given bug ID, the corresponding item in the returned list will + be None.''' + return [(b and _Bug(self, dict=b, + autorefresh=self.bug_autorefresh)) or None + for b in self._getbugs(idlist, simple=True)] + + + ################# + # query methods # + ################# + + def _convert_include_field_list(self, _in): + if not _in: + return _in + + for newname, oldname in self._get_api_aliases(): + if oldname in _in: + _in.remove(oldname) + if newname not in _in: + _in.append(newname) + return _in + + def build_query(self, + product=None, + component=None, + version=None, + long_desc=None, + bug_id=None, + short_desc=None, + cc=None, + assigned_to=None, + reporter=None, + qa_contact=None, + status=None, + blocked=None, + dependson=None, + keywords=None, + keywords_type=None, + url=None, + url_type=None, + status_whiteboard=None, + status_whiteboard_type=None, + fixed_in=None, + fixed_in_type=None, + flag=None, + alias=None, + qa_whiteboard=None, + devel_whiteboard=None, + boolean_query=None, + bug_severity=None, + priority=None, + target_milestone=None, + emailtype=None, + booleantype=None, + include_fields=None, + quicksearch=None, + savedsearch=None, + savedsearch_sharer_id=None, + sub_component=None, + tags=None): + """ + Build a query string from passed arguments. Will handle + query parameter differences between various bugzilla versions. + + Most of the parameters should be self explanatory. However + if you want to perform a complex query, and easy way is to + create it with the bugzilla web UI, copy the entire URL it + generates, and pass it to the static method + + Bugzilla.url_to_query + + Then pass the output to Bugzilla.query() + """ + ignore = emailtype + ignore = booleantype + ignore = include_fields + + for key, val in [ + ('fixed_in', fixed_in), + ('blocked', blocked), + ('dependson', dependson), + ('flag', flag), + ('qa_whiteboard', qa_whiteboard), + ('devel_whiteboard', devel_whiteboard), + ('alias', alias), + ('boolean_query', boolean_query), + ('long_desc', long_desc), + ('quicksearch', quicksearch), + ('savedsearch', savedsearch), + ('sharer_id', savedsearch_sharer_id), + ('sub_component', sub_component), + ]: + if val is not None: + raise RuntimeError("'%s' search not supported by this " + "bugzilla" % key) + + query = { + "product": self._listify(product), + "component": self._listify(component), + "version": version, + "id": bug_id, + "short_desc": short_desc, + "bug_status": status, + "keywords": keywords, + "keywords_type": keywords_type, + "bug_file_loc": url, + "bug_file_loc_type": url_type, + "status_whiteboard": status_whiteboard, + "status_whiteboard_type": status_whiteboard_type, + "fixed_in_type": fixed_in_type, + "bug_severity": bug_severity, + "priority": priority, + "target_milestone": target_milestone, + "assigned_to": assigned_to, + "cc": cc, + "qa_contact": qa_contact, + "reporter": reporter, + "tag": self._listify(tags), + } + + # Strip out None elements in the dict + for k, v in query.copy().items(): + if v is None: + del(query[k]) + return query + + def _query(self, query): + # This is kinda redundant now, but various scripts call + # _query with their own assembled dictionaries, so don't + # drop this lest we needlessly break those users + log.debug("Calling Bug.search with: %s", query) + return self._proxy.Bug.search(query) + + def query(self, query): + '''Query bugzilla and return a list of matching bugs. + query must be a dict with fields like those in in querydata['fields']. + Returns a list of Bug objects. + Also see the _query() method for details about the underlying + implementation. + ''' + r = self._query(query) + log.debug("Query returned %s bugs", len(r['bugs'])) + return [_Bug(self, dict=b, + autorefresh=self.bug_autorefresh) for b in r['bugs']] + + def simplequery(self, product, version='', component='', + string='', matchtype='allwordssubstr'): + '''Convenience method - query for bugs filed against the given + product, version, and component whose comments match the given string. + matchtype specifies the type of match to be done. matchtype may be + any of the types listed in querydefaults['long_desc_type_list'], e.g.: + ['allwordssubstr', 'anywordssubstr', 'substring', 'casesubstring', + 'allwords', 'anywords', 'regexp', 'notregexp'] + Return value is the same as with query(). + ''' + q = { + 'product': product, + 'version': version, + 'component': component, + 'long_desc': string, + 'long_desc_type': matchtype + } + return self.query(q) + + def pre_translation(self, query): + '''In order to keep the API the same, Bugzilla4 needs to process the + query and the result. This also applies to the refresh() function + ''' + pass + + def post_translation(self, query, bug): + '''In order to keep the API the same, Bugzilla4 needs to process the + query and the result. This also applies to the refresh() function + ''' + pass + + def bugs_history(self, bug_ids): + ''' + Experimental. Gets the history of changes for + particular bugs in the database. + ''' + return self._proxy.Bug.history({'ids': bug_ids}) + + ####################################### + # Methods for modifying existing bugs # + ####################################### + + # Bug() also has individual methods for many ops, like setassignee() + + def update_bugs(self, ids, updates): + """ + A thin wrapper around bugzilla Bug.update(). Used to update all + values of an existing bug report, as well as add comments. + + The dictionary passed to this function should be generated with + build_update(), otherwise we cannot guarantee back compatibility. + """ + tmp = updates.copy() + tmp["ids"] = self._listify(ids) + + log.debug("Calling Bug.update with: %s", tmp) + return self._proxy.Bug.update(tmp) + + def update_flags(self, idlist, flags): + ''' + Updates the flags associated with a bug report. + Format of flags is: + [{"name": "needinfo", "status": "+", "requestee": "[email protected]"}, + {"name": "devel_ack", "status": "-"}, ...] + ''' + d = {"ids": self._listify(idlist), "updates": flags} + log.debug("Calling Flag.update with: %s", d) + return self._proxy.Flag.update(d) + + def update_tags(self, idlist, tags_add=None, tags_remove=None): + ''' + Updates the 'tags' field for a bug. + ''' + tags = {} + if tags_add: + tags["add"] = self._listify(tags_add) + if tags_remove: + tags["remove"] = self._listify(tags_remove) + + d = { + "ids": self._listify(idlist), + "tags": tags, + } + + log.debug("Calling Bug.update_tags with: %s", d) + return self._proxy.Bug.update_tags(d) + + + def build_update(self, + alias=None, + assigned_to=None, + blocks_add=None, + blocks_remove=None, + blocks_set=None, + depends_on_add=None, + depends_on_remove=None, + depends_on_set=None, + cc_add=None, + cc_remove=None, + is_cc_accessible=None, + comment=None, + comment_private=None, + component=None, + deadline=None, + dupe_of=None, + estimated_time=None, + groups_add=None, + groups_remove=None, + keywords_add=None, + keywords_remove=None, + keywords_set=None, + op_sys=None, + platform=None, + priority=None, + product=None, + qa_contact=None, + is_creator_accessible=None, + remaining_time=None, + reset_assigned_to=None, + reset_qa_contact=None, + resolution=None, + see_also_add=None, + see_also_remove=None, + severity=None, + status=None, + summary=None, + target_milestone=None, + target_release=None, + url=None, + version=None, + whiteboard=None, + work_time=None, + fixed_in=None, + qa_whiteboard=None, + devel_whiteboard=None, + internal_whiteboard=None, + sub_component=None): + # pylint: disable=W0221 + # Argument number differs from overridden method + # Base defines it with *args, **kwargs, so we don't have to maintain + # the master argument list in 2 places + ret = {} + + # These are only supported for rhbugzilla + for key, val in [ + ("fixed_in", fixed_in), + ("devel_whiteboard", devel_whiteboard), + ("qa_whiteboard", qa_whiteboard), + ("internal_whiteboard", internal_whiteboard), + ("sub_component", sub_component), + ]: + if val is not None: + raise ValueError("bugzilla instance does not support " + "updating '%s'" % key) + + def s(key, val, convert=None): + if val is None: + return + if convert: + val = convert(val) + ret[key] = val + + def add_dict(key, add, remove, _set=None, convert=None): + if add is remove is _set is None: + return + + def c(val): + val = self._listify(val) + if convert: + val = [convert(v) for v in val] + return val + + newdict = {} + if add is not None: + newdict["add"] = c(add) + if remove is not None: + newdict["remove"] = c(remove) + if _set is not None: + newdict["set"] = c(_set) + ret[key] = newdict + + + s("alias", alias) + s("assigned_to", assigned_to) + s("is_cc_accessible", is_cc_accessible, bool) + s("component", component) + s("deadline", deadline) + s("dupe_of", dupe_of, int) + s("estimated_time", estimated_time, int) + s("op_sys", op_sys) + s("platform", platform) + s("priority", priority) + s("product", product) + s("qa_contact", qa_contact) + s("is_creator_accessible", is_creator_accessible, bool) + s("remaining_time", remaining_time, float) + s("reset_assigned_to", reset_assigned_to, bool) + s("reset_qa_contact", reset_qa_contact, bool) + s("resolution", resolution) + s("severity", severity) + s("status", status) + s("summary", summary) + s("target_milestone", target_milestone) + s("target_release", target_release) + s("url", url) + s("version", version) + s("whiteboard", whiteboard) + s("work_time", work_time, float) + + add_dict("blocks", blocks_add, blocks_remove, blocks_set, + convert=int) + add_dict("depends_on", depends_on_add, depends_on_remove, + depends_on_set, convert=int) + add_dict("cc", cc_add, cc_remove) + add_dict("groups", groups_add, groups_remove) + add_dict("keywords", keywords_add, keywords_remove, keywords_set) + add_dict("see_also", see_also_add, see_also_remove) + + if comment is not None: + ret["comment"] = {"comment": comment} + if comment_private: + ret["comment"]["is_private"] = comment_private + + return ret + + + ######################################## + # Methods for working with attachments # + ######################################## + + def _attachment_uri(self, attachid): + '''Returns the URI for the given attachment ID.''' + att_uri = self.url.replace('xmlrpc.cgi', 'attachment.cgi') + att_uri = att_uri + '?id=%s' % attachid + return att_uri + + def attachfile(self, idlist, attachfile, description, **kwargs): + ''' + Attach a file to the given bug IDs. Returns the ID of the attachment + or raises XMLRPC Fault if something goes wrong. + + attachfile may be a filename (which will be opened) or a file-like + object, which must provide a 'read' method. If it's not one of these, + this method will raise a TypeError. + description is the short description of this attachment. + + Optional keyword args are as follows: + file_name: this will be used as the filename for the attachment. + REQUIRED if attachfile is a file-like object with no + 'name' attribute, otherwise the filename or .name + attribute will be used. + comment: An optional comment about this attachment. + is_private: Set to True if the attachment should be marked private. + is_patch: Set to True if the attachment is a patch. + content_type: The mime-type of the attached file. Defaults to + application/octet-stream if not set. NOTE that text + files will *not* be viewable in bugzilla unless you + remember to set this to text/plain. So remember that! + + Returns the list of attachment ids that were added. If only one + attachment was added, we return the single int ID for back compat + ''' + if isinstance(attachfile, str): + f = open(attachfile) + elif hasattr(attachfile, 'read'): + f = attachfile + else: + raise TypeError("attachfile must be filename or file-like object") + + # Back compat + if "contenttype" in kwargs: + kwargs["content_type"] = kwargs.pop("contenttype") + if "ispatch" in kwargs: + kwargs["is_patch"] = kwargs.pop("ispatch") + if "isprivate" in kwargs: + kwargs["is_private"] = kwargs.pop("isprivate") + if "filename" in kwargs: + kwargs["file_name"] = kwargs.pop("filename") + + kwargs['summary'] = description + + data = f.read() + if not isinstance(data, bytes): + data = data.encode(locale.getpreferredencoding()) + kwargs['data'] = Binary(data) + + kwargs['ids'] = self._listify(idlist) + + if 'file_name' not in kwargs and hasattr(f, "name"): + kwargs['file_name'] = os.path.basename(f.name) + if 'content_type' not in kwargs: + ctype = _detect_filetype(getattr(f, "name", None)) + if not ctype: + ctype = 'application/octet-stream' + kwargs['content_type'] = ctype + + ret = self._proxy.Bug.add_attachment(kwargs) + + if "attachments" in ret: + # Up to BZ 4.2 + ret = [int(k) for k in ret["attachments"].keys()] + elif "ids" in ret: + # BZ 4.4+ + ret = ret["ids"] + + if type(ret) is list and len(ret) == 1: + ret = ret[0] + return ret + + + def openattachment(self, attachid): + '''Get the contents of the attachment with the given attachment ID. + Returns a file-like object.''' + + def get_filename(headers): + import re + + match = re.search( + r'^.*filename="?(.*)"$', + headers.get('content-disposition', '') + ) + + # default to attchid if no match was found + return match.group(1) if match else attachid + + att_uri = self._attachment_uri(attachid) + + response = requests.get(att_uri, cookies=self._cookiejar, stream=True) + + ret = BytesIO() + for chunk in response.iter_content(chunk_size=1024): + if chunk: + ret.write(chunk) + ret.name = get_filename(response.headers) + + # Hooray, now we have a file-like object with .read() and .name + ret.seek(0) + return ret + + def updateattachmentflags(self, bugid, attachid, flagname, **kwargs): + ''' + Updates a flag for the given attachment ID. + Optional keyword args are: + status: new status for the flag ('-', '+', '?', 'X') + requestee: new requestee for the flag + ''' + update = { + 'name': flagname, + 'attach_id': int(attachid), + } + update.update(kwargs.items()) + + result = self._proxy.Flag.update({ + 'ids': [int(bugid)], + 'updates': [update]}) + return result['flag_updates'][str(bugid)] + + + ##################### + # createbug methods # + ##################### + + createbug_required = ('product', 'component', 'summary', 'version', + 'description') + + def build_createbug(self, + product=None, + component=None, + version=None, + summary=None, + description=None, + comment_private=None, + blocks=None, + cc=None, + assigned_to=None, + keywords=None, + depends_on=None, + groups=None, + op_sys=None, + platform=None, + priority=None, + qa_contact=None, + resolution=None, + severity=None, + status=None, + target_milestone=None, + target_release=None, + url=None, + sub_component=None): + + localdict = {} + if blocks: + localdict["blocks"] = self._listify(blocks) + if cc: + localdict["cc"] = self._listify(cc) + if depends_on: + localdict["depends_on"] = self._listify(depends_on) + if groups: + localdict["groups"] = self._listify(groups) + if keywords: + localdict["keywords"] = self._listify(keywords) + if description: + localdict["description"] = description + if comment_private: + localdict["comment_is_private"] = True + + # Most of the machinery and formatting here is the same as + # build_update, so reuse that as much as possible + ret = self.build_update(product=product, component=component, + version=version, summary=summary, op_sys=op_sys, + platform=platform, priority=priority, qa_contact=qa_contact, + resolution=resolution, severity=severity, status=status, + target_milestone=target_milestone, + target_release=target_release, url=url, + assigned_to=assigned_to, sub_component=sub_component) + + ret.update(localdict) + return ret + + def _validate_createbug(self, *args, **kwargs): + # Previous API required users specifying keyword args that mapped + # to the XMLRPC arg names. Maintain that bad compat, but also allow + # receiving a single dictionary like query() does + if kwargs and args: + raise BugzillaError("createbug: cannot specify positional " + "args=%s with kwargs=%s, must be one or the " + "other." % (args, kwargs)) + if args: + if len(args) > 1 or type(args[0]) is not dict: + raise BugzillaError("createbug: positional arguments only " + "accept a single dictionary.") + data = args[0] + else: + data = kwargs + + # If we're getting a call that uses an old fieldname, convert it to the + # new fieldname instead. + for newname, oldname in self._get_api_aliases(): + if (newname in self.createbug_required and + newname not in data and + oldname in data): + data[newname] = data.pop(oldname) + + # Back compat handling for check_args + if "check_args" in data: + del(data["check_args"]) + + return data + + def createbug(self, *args, **kwargs): + ''' + Create a bug with the given info. Returns a new Bug object. + Check bugzilla API documentation for valid values, at least + product, component, summary, version, and description need to + be passed. + ''' + data = self._validate_createbug(*args, **kwargs) + log.debug("Calling Bug.create with: %s", data) + rawbug = self._proxy.Bug.create(data) + return _Bug(self, bug_id=rawbug["id"], + autorefresh=self.bug_autorefresh) + + + ############################## + # Methods for handling Users # + ############################## + + def _getusers(self, ids=None, names=None, match=None): + '''Return a list of users that match criteria. + + :kwarg ids: list of user ids to return data on + :kwarg names: list of user names to return data on + :kwarg match: list of patterns. Returns users whose real name or + login name match the pattern. + :raises XMLRPC Fault: Code 51: if a Bad Login Name was sent to the + names array. + Code 304: if the user was not authorized to see user they + requested. + Code 505: user is logged out and can't use the match or ids + parameter. + + Available in Bugzilla-3.4+ + ''' + params = {} + if ids: + params['ids'] = self._listify(ids) + if names: + params['names'] = self._listify(names) + if match: + params['match'] = self._listify(match) + if not params: + raise BugzillaError('_get() needs one of ids, ' + ' names, or match kwarg.') + + log.debug("Calling User.get with: %s", params) + return self._proxy.User.get(params) + + def getuser(self, username): + '''Return a bugzilla User for the given username + + :arg username: The username used in bugzilla. + :raises XMLRPC Fault: Code 51 if the username does not exist + :returns: User record for the username + ''' + ret = self.getusers(username) + return ret and ret[0] + + def getusers(self, userlist): + '''Return a list of Users from bugzilla. + + :userlist: List of usernames to lookup + :returns: List of User records + ''' + userobjs = [_User(self, **rawuser) for rawuser in + self._getusers(names=userlist).get('users', [])] + + # Return users in same order they were passed in + ret = [] + for u in userlist: + for uobj in userobjs[:]: + if uobj.email == u: + userobjs.remove(uobj) + ret.append(uobj) + break + ret += userobjs + return ret + + + def searchusers(self, pattern): + '''Return a bugzilla User for the given list of patterns + + :arg pattern: List of patterns to match against. + :returns: List of User records + ''' + return [_User(self, **rawuser) for rawuser in + self._getusers(match=pattern).get('users', [])] + + def createuser(self, email, name='', password=''): + '''Return a bugzilla User for the given username + + :arg email: The email address to use in bugzilla + :kwarg name: Real name to associate with the account + :kwarg password: Password to set for the bugzilla account + :raises XMLRPC Fault: Code 501 if the username already exists + Code 500 if the email address isn't valid + Code 502 if the password is too short + Code 503 if the password is too long + :return: User record for the username + ''' + self._proxy.User.create(email, name, password) + return self.getuser(email) + + def updateperms(self, user, action, groups): + ''' + A method to update the permissions (group membership) of a bugzilla + user. + + :arg user: The e-mail address of the user to be acted upon. Can + also be a list of emails. + :arg action: add, remove, or set + :arg groups: list of groups to be added to (i.e. ['fedora_contrib']) + ''' + groups = self._listify(groups) + if action == "rem": + action = "remove" + if action not in ["add", "remove", "set"]: + raise BugzillaError("Unknown user permission action '%s'" % action) + + update = { + "names": self._listify(user), + "groups": { + action: groups, + } + } + + log.debug("Call User.update with: %s", update) + return self._proxy.User.update(update) + + + ###################### + # Deprecated methods # + ###################### + + def initcookiefile(self, cookiefile=None): + ''' + Deprecated: Set self.cookiefile instead. + ''' + if not cookiefile: + cookiefile = os.path.expanduser('~/.bugzillacookies') + self.cookiefile = cookiefile + + + def adduser(self, user, name): + '''Deprecated: Use createuser() instead. + + A method to create a user in Bugzilla. Takes the following: + + user: The email address of the user to create + name: The full name of the user to create + ''' + self.createuser(user, name) + + def getqueryinfo(self, force_refresh=False): + ignore = force_refresh + raise RuntimeError("getqueryinfo is deprecated and the " + "information is not provided by any modern bugzilla.") + querydata = property(getqueryinfo) + querydefaults = property(getqueryinfo) diff --git a/ciabot/bugzilla/bug.py b/ciabot/bugzilla/bug.py new file mode 100644 index 0000000..80d9720 --- /dev/null +++ b/ciabot/bugzilla/bug.py @@ -0,0 +1,517 @@ +# base.py - the base classes etc. for a Python interface to bugzilla +# +# Copyright (C) 2007, 2008, 2009, 2010 Red Hat Inc. +# Author: Will Woods <[email protected]> +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. See http://www.gnu.org/copyleft/gpl.html for +# the full text of the license. + +import locale +import sys + +from bugzilla import log + + +class _Bug(object): + '''A container object for a bug report. Requires a Bugzilla instance - + every Bug is on a Bugzilla, obviously. + Optional keyword args: + dict=DICT - populate attributes with the result of a getBug() call + bug_id=ID - if dict does not contain bug_id, this is required before + you can read any attributes or make modifications to this + bug. + ''' + def __init__(self, bugzilla, bug_id=None, dict=None, autorefresh=True): + # pylint: disable=redefined-builtin + # API had pre-existing issue that we can't change ('dict' usage) + + self.bugzilla = bugzilla + self._bug_fields = [] + self.autorefresh = autorefresh + + if bug_id: + if not dict: + dict = {} + dict["id"] = bug_id + + if dict: + log.debug("Bug(%s)", sorted(dict.keys())) + self._update_dict(dict) + + self.weburl = bugzilla.url.replace('xmlrpc.cgi', + 'show_bug.cgi?id=%i' % self.bug_id) + + def __str__(self): + '''Return a simple string representation of this bug + + This is available only for compatibility. Using 'str(bug)' and + 'print(bug)' is not recommended because of potential encoding issues. + Please use unicode(bug) where possible. + ''' + if hasattr(sys.version_info, "major") and sys.version_info.major >= 3: + return self.__unicode__() + else: + return self.__unicode__().encode( + locale.getpreferredencoding(), 'replace') + + def __unicode__(self): + '''Return a simple unicode string representation of this bug''' + return u"#%-6s %-10s - %s - %s" % (self.bug_id, self.bug_status, + self.assigned_to, self.summary) + + def __repr__(self): + return '<Bug #%i on %s at %#x>' % (self.bug_id, self.bugzilla.url, + id(self)) + + def __getattr__(self, name): + refreshed = False + while True: + if refreshed and name in self.__dict__: + # If name was in __dict__ to begin with, __getattr__ would + # have never been called. + return self.__dict__[name] + + # pylint: disable=protected-access + aliases = self.bugzilla._get_bug_aliases() + # pylint: enable=protected-access + + for newname, oldname in aliases: + if name == oldname and newname in self.__dict__: + return self.__dict__[newname] + + # Doing dir(bugobj) does getattr __members__/__methods__, + # don't refresh for those + if name.startswith("__") and name.endswith("__"): + break + + if refreshed or not self.autorefresh: + break + + log.info("Bug %i missing attribute '%s' - doing implicit " + "refresh(). This will be slow, if you want to avoid " + "this, properly use query/getbug include_fields, and " + "set bugzilla.bug_autorefresh = False to force failure.", + self.bug_id, name) + + # We pass the attribute name to getbug, since for something like + # 'attachments' which downloads lots of data we really want the + # user to opt in. + self.refresh(extra_fields=[name]) + refreshed = True + + raise AttributeError("Bug object has no attribute '%s'" % name) + + def refresh(self, include_fields=None, exclude_fields=None, + extra_fields=None): + ''' + Refresh the bug with the latest data from bugzilla + ''' + # pylint: disable=protected-access + r = self.bugzilla._getbug(self.bug_id, + include_fields=include_fields, exclude_fields=exclude_fields, + extra_fields=self._bug_fields + (extra_fields or [])) + # pylint: enable=protected-access + self._update_dict(r) + reload = refresh + + def _update_dict(self, newdict): + ''' + Update internal dictionary, in a way that ensures no duplicate + entries are stored WRT field aliases + ''' + if self.bugzilla: + self.bugzilla.post_translation({}, newdict) + + # pylint: disable=protected-access + aliases = self.bugzilla._get_bug_aliases() + # pylint: enable=protected-access + + for newname, oldname in aliases: + if oldname not in newdict: + continue + + if newname not in newdict: + newdict[newname] = newdict[oldname] + elif newdict[newname] != newdict[oldname]: + log.debug("Update dict contained differing alias values " + "d[%s]=%s and d[%s]=%s , dropping the value " + "d[%s]", newname, newdict[newname], oldname, + newdict[oldname], oldname) + del(newdict[oldname]) + + for key in newdict.keys(): + if key not in self._bug_fields: + self._bug_fields.append(key) + self.__dict__.update(newdict) + + if 'id' not in self.__dict__ and 'bug_id' not in self.__dict__: + raise TypeError("Bug object needs a bug_id") + + + ################## + # pickle helpers # + ################## + + def __getstate__(self): + ret = {} + for key in self._bug_fields: + ret[key] = self.__dict__[key] + return ret + + def __setstate__(self, vals): + self._bug_fields = [] + self.bugzilla = None + self._update_dict(vals) + + + ##################### + # Modify bug status # + ##################### + + def setstatus(self, status, comment=None, private=False, + private_in_it=False, nomail=False): + ''' + Update the status for this bug report. + Commonly-used values are ASSIGNED, MODIFIED, and NEEDINFO. + + To change bugs to CLOSED, use .close() instead. + ''' + ignore = private_in_it + ignore = nomail + + vals = self.bugzilla.build_update(status=status, + comment=comment, + comment_private=private) + log.debug("setstatus: update=%s", vals) + + return self.bugzilla.update_bugs(self.bug_id, vals) + + def close(self, resolution, dupeid=None, fixedin=None, + comment=None, isprivate=False, + private_in_it=False, nomail=False): + '''Close this bug. + Valid values for resolution are in bz.querydefaults['resolution_list'] + For bugzilla.redhat.com that's: + ['NOTABUG', 'WONTFIX', 'DEFERRED', 'WORKSFORME', 'CURRENTRELEASE', + 'RAWHIDE', 'ERRATA', 'DUPLICATE', 'UPSTREAM', 'NEXTRELEASE', + 'CANTFIX', 'INSUFFICIENT_DATA'] + If using DUPLICATE, you need to set dupeid to the ID of the other bug. + If using WORKSFORME/CURRENTRELEASE/RAWHIDE/ERRATA/UPSTREAM/NEXTRELEASE + you can (and should) set 'new_fixed_in' to a string representing the + version that fixes the bug. + You can optionally add a comment while closing the bug. Set 'isprivate' + to True if you want that comment to be private. + ''' + ignore = private_in_it + ignore = nomail + + vals = self.bugzilla.build_update(comment=comment, + comment_private=isprivate, + resolution=resolution, + dupe_of=dupeid, + fixed_in=fixedin, + status="CLOSED") + log.debug("close: update=%s", vals) + + return self.bugzilla.update_bugs(self.bug_id, vals) + + + ##################### + # Modify bug emails # + ##################### + + def setassignee(self, assigned_to=None, reporter=None, + qa_contact=None, comment=None): + ''' + Set any of the assigned_to or qa_contact fields to a new + bugzilla account, with an optional comment, e.g. + setassignee(assigned_to='[email protected]') + setassignee(qa_contact='[email protected]', comment='wwoods QA ftw') + + You must set at least one of the two assignee fields, or this method + will throw a ValueError. + + Returns [bug_id, mailresults]. + ''' + if reporter: + raise ValueError("reporter can not be changed") + + if not (assigned_to or qa_contact): + raise ValueError("You must set one of assigned_to " + " or qa_contact") + + vals = self.bugzilla.build_update(assigned_to=assigned_to, + qa_contact=qa_contact, + comment=comment) + log.debug("setassignee: update=%s", vals) + + return self.bugzilla.update_bugs(self.bug_id, vals) + + def addcc(self, cclist, comment=None): + ''' + Adds the given email addresses to the CC list for this bug. + cclist: list of email addresses (strings) + comment: optional comment to add to the bug + ''' + vals = self.bugzilla.build_update(comment=comment, + cc_add=cclist) + log.debug("addcc: update=%s", vals) + + return self.bugzilla.update_bugs(self.bug_id, vals) + + def deletecc(self, cclist, comment=None): + ''' + Removes the given email addresses from the CC list for this bug. + ''' + vals = self.bugzilla.build_update(comment=comment, + cc_remove=cclist) + log.debug("deletecc: update=%s", vals) + + return self.bugzilla.update_bugs(self.bug_id, vals) + + + ############### + # Add comment # + ############### + + def addcomment(self, comment, private=False, + timestamp=None, worktime=None, bz_gid=None): + ''' + Add the given comment to this bug. Set private to True to mark this + comment as private. + ''' + ignore = timestamp + ignore = bz_gid + ignore = worktime + + vals = self.bugzilla.build_update(comment=comment, + comment_private=private) + log.debug("addcomment: update=%s", vals) + + return self.bugzilla.update_bugs(self.bug_id, vals) + + + ########################## + # Get/set bug whiteboard # + ########################## + + def _dowhiteboard(self, text, which, action, comment, private): + ''' + Update the whiteboard given by 'which' for the given bug. + ''' + if which not in ["status", "qa", "devel", "internal"]: + raise ValueError("Unknown whiteboard type '%s'" % which) + + if not which.endswith('_whiteboard'): + which = which + '_whiteboard' + if which == "status_whiteboard": + which = "whiteboard" + + if action != 'overwrite': + wb = getattr(self, which, '').strip() + tags = wb.split() + + sep = " " + for t in tags: + if t.endswith(","): + sep = ", " + + if action == 'prepend': + text = text + sep + wb + elif action == 'append': + text = wb + sep + text + else: + raise ValueError("Unknown whiteboard action '%s'" % action) + + updateargs = {which: text} + vals = self.bugzilla.build_update(comment=comment, + comment_private=private, + **updateargs) + log.debug("_updatewhiteboard: update=%s", vals) + + self.bugzilla.update_bugs(self.bug_id, vals) + + + def appendwhiteboard(self, text, which='status', + comment=None, private=False): + '''Append the given text (with a space before it) to the given + whiteboard. Defaults to using status_whiteboard.''' + self._dowhiteboard(text, which, "append", comment, private) + + def prependwhiteboard(self, text, which='status', + comment=None, private=False): + '''Prepend the given text (with a space following it) to the given + whiteboard. Defaults to using status_whiteboard.''' + self._dowhiteboard(text, which, "prepend", comment, private) + + def setwhiteboard(self, text, which='status', + comment=None, private=False): + '''Overwrites the contents of the given whiteboard with the given text. + Defaults to using status_whiteboard.''' + self._dowhiteboard(text, which, "overwrite", comment, private) + + def addtag(self, tag, which='status'): + '''Adds the given tag to the given bug.''' + whiteboard = self.getwhiteboard(which) + if whiteboard: + self.appendwhiteboard(tag, which) + else: + self.setwhiteboard(tag, which) + + def gettags(self, which='status'): + '''Get a list of tags (basically just whitespace-split the given + whiteboard)''' + return self.getwhiteboard(which).split() + + def deltag(self, tag, which='status'): + '''Removes the given tag from the given bug.''' + tags = self.gettags(which) + for t in tags: + if t.strip(",") == tag: + tags.remove(t) + self.setwhiteboard(' '.join(tags), which) + + + ##################### + # Get/Set bug flags # + ##################### + + def get_flag_type(self, name): + """ + Return flag_type information for a specific flag + + Older RHBugzilla returned a lot more info here, but it was + non-upstream and is now gone. + """ + for t in self.flags: + if t['name'] == name: + return t + return None + + def get_flags(self, name): + """ + Return flag value information for a specific flag + """ + ft = self.get_flag_type(name) + if not ft: + return None + + return [ft] + + def get_flag_status(self, name): + """ + Return a flag 'status' field + + This method works only for simple flags that have only a 'status' field + with no "requestee" info, and no multiple values. For more complex + flags, use get_flags() to get extended flag value information. + """ + f = self.get_flags(name) + if not f: + return None + + # This method works only for simple flags that have only one + # value set. + assert len(f) <= 1 + + return f[0]['status'] + + + ######################## + # Experimental methods # + ######################## + + def get_history(self): + ''' + Experimental. Get the history of changes for this bug. + ''' + return self.bugzilla.bugs_history([self.bug_id]) + + ###################### + # Deprecated methods # + ###################### + + def getwhiteboard(self, which='status'): + ''' + Deprecated. Use bug.qa_whiteboard, bug.devel_whiteboard, etc. + ''' + return getattr(self, "%s_whiteboard" % which) + + def updateflags(self, flags): + ''' + Deprecated, use bugzilla.update_flags() directly + ''' + flaglist = [] + for key, value in flags.items(): + flaglist.append({"name": key, "status": value}) + return self.bugzilla.update_flags(self.bug_id, flaglist) + + +class _User(object): + '''Container object for a bugzilla User. + + :arg bugzilla: Bugzilla instance that this User belongs to. + Rest of the params come straight from User.get() + ''' + def __init__(self, bugzilla, **kwargs): + self.bugzilla = bugzilla + self.__userid = kwargs.get('id') + self.__name = kwargs.get('name') + + self.__email = kwargs.get('email', self.__name) + self.__can_login = kwargs.get('can_login', False) + + self.real_name = kwargs.get('real_name', None) + self.password = None + + self.groups = kwargs.get('groups', {}) + self.groupnames = [] + for g in self.groups: + if "name" in g: + self.groupnames.append(g["name"]) + self.groupnames.sort() + + + ######################## + # Read-only attributes # + ######################## + + # We make these properties so that the user cannot set them. They are + # unaffected by the update() method so it would be misleading to let them + # be changed. + @property + def userid(self): + return self.__userid + + @property + def email(self): + return self.__email + + @property + def can_login(self): + return self.__can_login + + # name is a key in some methods. Mark it dirty when we change it # + @property + def name(self): + return self.__name + + def refresh(self): + """ + Update User object with latest info from bugzilla + """ + newuser = self.bugzilla.getuser(self.email) + self.__dict__.update(newuser.__dict__) + + def updateperms(self, action, groups): + ''' + A method to update the permissions (group membership) of a bugzilla + user. + + :arg action: add, remove, or set + :arg groups: list of groups to be added to (i.e. ['fedora_contrib']) + ''' + self.bugzilla.updateperms(self.name, action, groups) diff --git a/ciabot/bugzilla/bugzilla3.py b/ciabot/bugzilla/bugzilla3.py new file mode 100644 index 0000000..efacdea --- /dev/null +++ b/ciabot/bugzilla/bugzilla3.py @@ -0,0 +1,34 @@ +# bugzilla3.py - a Python interface to Bugzilla 3.x using xmlrpclib. +# +# Copyright (C) 2008, 2009 Red Hat Inc. +# Author: Will Woods <[email protected]> +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. See http://www.gnu.org/copyleft/gpl.html for +# the full text of the license. + +from bugzilla.base import BugzillaBase + + +class Bugzilla3(BugzillaBase): + bz_ver_major = 3 + bz_ver_minor = 0 + + +class Bugzilla32(Bugzilla3): + bz_ver_minor = 2 + + +class Bugzilla34(Bugzilla32): + bz_ver_minor = 4 + + +class Bugzilla36(Bugzilla34): + bz_ver_minor = 6 + + def _getbugfields(self): + '''Get the list of valid fields for Bug objects''' + r = self._proxy.Bug.fields({'include_fields': ['name']}) + return [f['name'] for f in r['fields']] diff --git a/ciabot/bugzilla/bugzilla4.py b/ciabot/bugzilla/bugzilla4.py new file mode 100644 index 0000000..7f5e127 --- /dev/null +++ b/ciabot/bugzilla/bugzilla4.py @@ -0,0 +1,47 @@ +# +# Copyright (C) 2008-2012 Red Hat Inc. +# Author: Michal Novotny <[email protected]> +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. See http://www.gnu.org/copyleft/gpl.html for +# the full text of the license. + +from bugzilla.bugzilla3 import Bugzilla36 + + +class Bugzilla4(Bugzilla36): + bz_ver_major = 4 + bz_ver_minor = 0 + + + ################# + # Query Methods # + ################# + + def build_query(self, **kwargs): + query = Bugzilla36.build_query(self, **kwargs) + + # 'include_fields' only available for Bugzilla4+ + include_fields = self._convert_include_field_list( + kwargs.pop('include_fields', None)) + if include_fields: + if 'id' not in include_fields: + include_fields.append('id') + query["include_fields"] = include_fields + + exclude_fields = self._convert_include_field_list( + kwargs.pop('exclude_fields', None)) + if exclude_fields: + query["exclude_fields"] = exclude_fields + + return query + + +class Bugzilla42(Bugzilla4): + bz_ver_minor = 2 + + +class Bugzilla44(Bugzilla42): + bz_ver_minor = 4 diff --git a/ciabot/bugzilla/rhbugzilla.py b/ciabot/bugzilla/rhbugzilla.py new file mode 100644 index 0000000..4c6c7e6 --- /dev/null +++ b/ciabot/bugzilla/rhbugzilla.py @@ -0,0 +1,368 @@ +# rhbugzilla.py - a Python interface to Red Hat Bugzilla using xmlrpclib. +# +# Copyright (C) 2008-2012 Red Hat Inc. +# Author: Will Woods <[email protected]> +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at your +# option) any later version. See http://www.gnu.org/copyleft/gpl.html for +# the full text of the license. + + +from bugzilla import log +from bugzilla.bugzilla4 import Bugzilla44 as _parent + + +class RHBugzilla(_parent): + ''' + Bugzilla class for connecting Red Hat's forked bugzilla instance, + bugzilla.redhat.com + + Historically this class used many more non-upstream methods, but + in 2012 RH started dropping most of its custom bits. By that time, + upstream BZ had most of the important functionality. + + Much of the remaining code here is just trying to keep things operating + in python-bugzilla back compatible manner. + + This class was written using bugzilla.redhat.com's API docs: + https://bugzilla.redhat.com/docs/en/html/api/ + ''' + + def __init__(self, *args, **kwargs): + """ + @rhbz_back_compat: If True, convert parameters to the format they were + in prior RHBZ upgrade in June 2012. Mostly this replaces lists + with comma separated strings, and alters groups and flags. + Default is False. Please don't use this in new code, just update + your scripts. + @multicall: Unused nowadays, will be removed in the future + """ + # 'multicall' is no longer used, just ignore it + multicall = kwargs.pop("multicall", None) + self.rhbz_back_compat = bool(kwargs.pop("rhbz_back_compat", False)) + + if multicall is not None: + log.warn("multicall is unused and will be removed in a " + "future release.") + + if self.rhbz_back_compat: + log.warn("rhbz_back_compat will be removed in a future release.") + + _parent.__init__(self, *args, **kwargs) + + def _add_both_alias(newname, origname): + self._add_field_alias(newname, origname, is_api=False) + self._add_field_alias(origname, newname, is_bug=False) + + _add_both_alias('fixed_in', 'cf_fixed_in') + _add_both_alias('qa_whiteboard', 'cf_qa_whiteboard') + _add_both_alias('devel_whiteboard', 'cf_devel_whiteboard') + _add_both_alias('internal_whiteboard', 'cf_internal_whiteboard') + + self._add_field_alias('component', 'components', is_bug=False) + self._add_field_alias('version', 'versions', is_bug=False) + self._add_field_alias('sub_component', 'sub_components', is_bug=False) + + # flags format isn't exactly the same but it's the closest approx + self._add_field_alias('flags', 'flag_types') + + self._getbug_extra_fields = self._getbug_extra_fields + [ + "comments", "description", + "external_bugs", "flags", "sub_components", + "tags", + ] + self._supports_getbug_extra_fields = True + + + ###################### + # Bug update methods # + ###################### + + def build_update(self, **kwargs): + adddict = {} + + def pop(key, destkey): + val = kwargs.pop(key, None) + if val is None: + return + adddict[destkey] = val + + def get_sub_component(): + val = kwargs.pop("sub_component", None) + if val is None: + return + + if type(val) is not dict: + component = self._listify(kwargs.get("component")) + if not component: + raise ValueError("component must be specified if " + "specifying sub_component") + val = {component[0]: val} + adddict["sub_components"] = val + + pop("fixed_in", "cf_fixed_in") + pop("qa_whiteboard", "cf_qa_whiteboard") + pop("devel_whiteboard", "cf_devel_whiteboard") + pop("internal_whiteboard", "cf_internal_whiteboard") + + get_sub_component() + + vals = _parent.build_update(self, **kwargs) + vals.update(adddict) + + return vals + + + ################# + # Query methods # + ################# + + def pre_translation(self, query): + '''Translates the query for possible aliases''' + old = query.copy() + + if 'bug_id' in query: + if type(query['bug_id']) is not list: + query['id'] = query['bug_id'].split(',') + else: + query['id'] = query['bug_id'] + del query['bug_id'] + + if 'component' in query: + if type(query['component']) is not list: + query['component'] = query['component'].split(',') + + if 'include_fields' not in query and 'column_list' not in query: + return + + if 'include_fields' not in query: + query['include_fields'] = [] + if 'column_list' in query: + query['include_fields'] = query['column_list'] + del query['column_list'] + + # We need to do this for users here for users that + # don't call build_query + self._convert_include_field_list(query['include_fields']) + + if old != query: + log.debug("RHBugzilla pretranslated query to: %s", query) + + def post_translation(self, query, bug): + ''' + Convert the results of getbug back to the ancient RHBZ value + formats + ''' + ignore = query + + # RHBZ _still_ returns component and version as lists, which + # deviates from upstream. Copy the list values to components + # and versions respectively. + if 'component' in bug and "components" not in bug: + val = bug['component'] + bug['components'] = type(val) is list and val or [val] + bug['component'] = bug['components'][0] + + if 'version' in bug and "versions" not in bug: + val = bug['version'] + bug['versions'] = type(val) is list and val or [val] + bug['version'] = bug['versions'][0] + + # sub_components isn't too friendly of a format, add a simpler + # sub_component value + if 'sub_components' in bug and 'sub_component' not in bug: + val = bug['sub_components'] + bug['sub_component'] = "" + if type(val) is dict: + values = [] + for vallist in val.values(): + values += vallist + bug['sub_component'] = " ".join(values) + + if not self.rhbz_back_compat: + return + + if 'flags' in bug and type(bug["flags"]) is list: + tmpstr = [] + for tmp in bug['flags']: + tmpstr.append("%s%s" % (tmp['name'], tmp['status'])) + + bug['flags'] = ",".join(tmpstr) + + if 'blocks' in bug and type(bug["blocks"]) is list: + # Aliases will handle the 'blockedby' and 'blocked' back compat + bug['blocks'] = ','.join([str(b) for b in bug['blocks']]) + + if 'keywords' in bug and type(bug["keywords"]) is list: + bug['keywords'] = ','.join(bug['keywords']) + + if 'alias' in bug and type(bug["alias"]) is list: + bug['alias'] = ','.join(bug['alias']) + + if ('groups' in bug and + type(bug["groups"]) is list and + len(bug["groups"]) > 0 and + type(bug["groups"][0]) is str): + # groups went to the opposite direction: it got simpler + # instead of having name, ison, description, it's now just + # an array of strings of the groups the bug belongs to + # we're emulating the old behaviour here + tmp = [] + for g in bug['groups']: + t = {} + t['name'] = g + t['description'] = g + t['ison'] = 1 + tmp.append(t) + bug['groups'] = tmp + + def build_query(self, **kwargs): + query = {} + + def _add_key(paramname, keyname, listify=False): + val = kwargs.pop(paramname, None) + if val is None: ... etc. - the rest is truncated _______________________________________________ Libreoffice-commits mailing list [email protected] http://lists.freedesktop.org/mailman/listinfo/libreoffice-commits
