[Libreoffice-commits] dev-tools.git: ciabot/bugzilla
Miklos Vajna
vmiklos at collabora.co.uk
Wed Oct 7 05:12:23 PDT 2015
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 <vmiklos at collabora.co.uk>
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 <wwoods at redhat.com>
+#
+# 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 <wwoods at redhat.com>
+#
+# 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": "foo at bar.com"},
+ {"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 <wwoods at redhat.com>
+#
+# 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='wwoods at redhat.com')
+ setassignee(qa_contact='wwoods at redhat.com', 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 <wwoods at redhat.com>
+#
+# 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 <minovotn at redhat.com>
+#
+# 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 <wwoods at redhat.com>
+#
+# 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
More information about the Libreoffice-commits
mailing list