diff options
author | Will Woods <wwoods@redhat.com> | 2008-09-04 14:04:44 -0400 |
---|---|---|
committer | Will Woods <wwoods@redhat.com> | 2008-09-04 14:04:44 -0400 |
commit | bcb002af27ee4a224be7578ded886471231e598e (patch) | |
tree | da21e70d6243862417b99a7efff1786340ae64e9 /bugzilla | |
parent | 041a27dbc66b007f2e4c7132c229f14af5163982 (diff) | |
parent | ce753c44a0445b796cd19851cba8dff2e5d455d0 (diff) | |
download | python-bugzilla-bcb002af27ee4a224be7578ded886471231e598e.tar.gz python-bugzilla-bcb002af27ee4a224be7578ded886471231e598e.tar.xz python-bugzilla-bcb002af27ee4a224be7578ded886471231e598e.zip |
Merge from abstractify branch
Diffstat (limited to 'bugzilla')
-rwxr-xr-x | bugzilla | 306 | ||||
-rw-r--r-- | bugzilla/__init__.py | 74 | ||||
-rw-r--r-- | bugzilla/base.py | 936 | ||||
-rw-r--r-- | bugzilla/bugzilla3.py | 139 | ||||
-rw-r--r-- | bugzilla/rhbugzilla.py | 503 |
5 files changed, 1652 insertions, 306 deletions
diff --git a/bugzilla b/bugzilla deleted file mode 100755 index 865095d..0000000 --- a/bugzilla +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/python -# bugzilla - a commandline frontend for the python bugzilla module -# -# Copyright (C) 2007 Red Hat Inc. -# Author: Will Woods <wwoods@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 bugzilla, optparse -import os, sys, glob, re -import logging - -version = '0.2' -default_bz = 'https://bugzilla.redhat.com/xmlrpc.cgi' - -# Initial simple logging stuff -logging.basicConfig() -log = logging.getLogger("bugzilla") -if '--debug' in sys.argv: - log.setLevel(logging.DEBUG) -elif '--verbose' in sys.argv: - log.setLevel(logging.INFO) - -def findcookie(): - globs = ['~/.mozilla/firefox/*default*/cookies.txt'] - for g in globs: - log.debug("Looking for cookies.txt in %s", g) - cookiefiles = glob.glob(os.path.expanduser(g)) - if cookiefiles: - # return the first one we find. - # TODO: find all cookiefiles, sort by age, use newest - return cookiefiles[0] -cookiefile = None - -cmdlist = ('info','query','new','modify') -def setup_parser(): - u = "usage: %prog [global options] COMMAND [options]" - u += "\nCommands: %s" % ', '.join(cmdlist) - p = optparse.OptionParser(usage=u) - p.disable_interspersed_args() - # General bugzilla connection options - p.add_option('--bugzilla',default=default_bz, - help="bugzilla XMLRPC URI. default: %s" % default_bz) - p.add_option('--user', - help="username. Will attempt to use browser cookie if not specified.") - p.add_option('--password', - help="password. Will attempt to use browser cookie if not specified.") - p.add_option('--cookiefile', - help="cookie file to use for bugzilla authentication") - p.add_option('--verbose',action='store_true', - help="give more info about what's going on") - p.add_option('--debug',action='store_true', - help="output bunches of debugging info") - return p - -def setup_action_parser(action): - p = optparse.OptionParser(usage="usage: %%prog %s [options]" % action) - # TODO: product and version could default to current system - # info (read from /etc/redhat-release?) - if action == 'new': - p.add_option('-p','--product', - help="REQUIRED: product name (list with 'bugzilla info -p')") - p.add_option('-v','--version', - help="REQUIRED: product version") - p.add_option('-c','--component', - help="REQUIRED: component name (list with 'bugzilla info -c PRODUCT')") - p.add_option('-l','--comment', - help="REQUIRED: initial bug comment") - p.add_option('-s','--summary',dest='short_desc', - help="REQUIRED: bug summary") - p.add_option('-o','--os',default='Linux',dest='op_sys', - help="OPTIONAL: operating system (default: Linux)") - p.add_option('-a','--arch',default='All',dest='rep_platform', - help="OPTIONAL: arch this bug occurs on (default: All)") - p.add_option('--severity',default='medium',dest='bug_severity', - help="OPTIONAL: bug severity (default: medium)") - p.add_option('--priority',default='medium',dest='priority', - help="OPTIONAL: bug priority (default: medium)") - p.add_option('-u','--url',dest='bug_file_loc',default='http://', - help="OPTIONAL: URL for further bug info") - p.add_option('--cc', - help="OPTIONAL: add emails to initial CC list") - p.add_option('--blocked', - help="OPTIONAL: block bugs with this new bug") - p.add_option('--dependson', - help="OPTIONAL: mark this bug as depending on this list of bugs", default=' ') - # TODO: alias, assigned_to, reporter, qa_contact, dependson, blocked - elif action == 'query': - p.add_option('-p','--product', - help="product name (list with 'bugzilla info -p')") - p.add_option('-v','--version', - help="product version") - p.add_option('-c','--component', - help="component name (list with 'bugzilla info -c PRODUCT')") - p.add_option('-l','--long_desc', - help="search inside bug comments") - p.add_option('-s','--short_desc', - help="search bug summaries") - p.add_option('-o','--cc', - help="search cc lists for given address") - p.add_option('-r','--reporter', - help="search for bugs reported by this address") - p.add_option('-a','--assigned_to', - help="search for bugs assigned to this address") - p.add_option('--blocked', - help="search for bugs that block this bug ID") - p.add_option('--dependson', - help="search for bugs that depend on this bug ID") - p.add_option('-b','--bug_id', - help="specify individual bugs by IDs, separated with commas") - p.add_option('-t','--bug_status','--status', - default="NEW,VERIFIED,ASSIGNED,NEEDINFO,ON_DEV,FAILS_QA,REOPENED", - help="comma-separated list of bug statuses to accept") - elif action == 'info': - p.add_option('-p','--products',action='store_true', - help='Get a list of products') - p.add_option('-c','--components',metavar="PRODUCT", - help='List the components in the given product') - p.add_option('-o','--component_owners',metavar="PRODUCT", - help='List components (and their owners)') - p.add_option('-v','--versions',metavar="PRODUCT", - help='List the versions for the given product') - elif action == 'modify': - p.set_usage("usage: %prog modify [options] BUGID BUGID ...") - p.add_option('-l','--comment', - help='Add a comment') - # FIXME: check value for resolution - p.add_option('-k','--close',metavar="RESOLUTION", - help='Close with the given resolution') - # TODO: --keyword, --flag, --tag, --status, --assignee, --cc, ... - - if action in ('new','query'): - # output modifiers - p.add_option('-f','--full',action='store_const',dest='output', - const='full',default='normal',help="output detailed bug info") - p.add_option('-i','--ids',action='store_const',dest='output', - const='ids',help="output only bug IDs") - p.add_option('--outputformat', - help="Print output in the form given. You can use RPM-style "+ - "tags that match bug fields, e.g.: '%{bug_id}: %{short_desc}'") - return p - -if __name__ == '__main__': - # Set up parser for global args - parser = setup_parser() - # Parse the commandline, woo - (global_opt,args) = parser.parse_args() - # Get our action from these args - if len(args) and args[0] in cmdlist: - action = args.pop(0) - else: - parser.error("command must be one of: %s" % ','.join(cmdlist)) - # Parse action-specific args - action_parser = setup_action_parser(action) - (opt, args) = action_parser.parse_args(args) - - # Connect to bugzilla - log.info('Connecting to %s',global_opt.bugzilla) - bz=bugzilla.Bugzilla(url=global_opt.bugzilla) - if global_opt.user and global_opt.password: - log.info('Using username/password for authentication') - bz.login(global_opt.user,global_opt.password) - elif global_opt.cookiefile: - log.info('Using cookies in %s for authentication', global_opt.cookiefile) - bz.readcookiefile(global_opt.cookiefile) - else: - cookiefile = findcookie() - if cookiefile: - log.info('Using cookies in %s for authentication', cookiefile) - bz.readcookiefile(cookiefile) - else: - parser.error("Could not find a Firefox cookie file. Try --user/--password.") - - # And now we actually execute the given command - buglist = list() # save the results of query/new/modify here - if action == 'info': - if opt.products: - for k in sorted(bz.products): - print k - - if opt.components: - for c in sorted(bz.getcomponents(opt.components)): - print c - - if opt.component_owners: - component_details = bz.getcomponentsdetails(opt.component_owners) - for c in sorted(component_details): - print "%s: %s" % (c, component_details[c]['initialowner']) - - if opt.versions: - for p in bz.querydata['product']: - if p['name'] == opt.versions: - for v in p['versions']: - print v - - elif action == 'query': - # shortcut for specific bug_ids - if opt.bug_id: - log.debug("bz.getbugs(%s)", opt.bug_id.split(',')) - buglist=bz.getbugs(opt.bug_id.split(',')) - else: - # Construct the query from the list of queryable options - q = dict() - email_count = 1 - chart_id = 0 - for a in ('product','component','version','long_desc','bug_id', - 'short_desc','cc','assigned_to','reporter','bug_status', - 'blocked','dependson'): - if hasattr(opt,a): - i = getattr(opt,a) - if i: - if a in ('bug_status'): # list args - q[a] = i.split(',') - elif a in ('cc','assigned_to','reporter'): - # the email query fields are kind of weird - thanks - # to Florian La Roche for figuring this bit out. - # ex.: {'email1':'foo@bar.com','emailcc1':True} - q['email%i' % email_count] = i - q['email%s%i' % (a,email_count)] = True - email_count += 1 - elif a in ('blocked','dependson'): - # Chart args are weird. - q['field%i-0-0' % chart_id] = a - q['type%i-0-0' % chart_id] = 'equals' - q['value%i-0-0' % chart_id] = i - chart_id += 1 - else: - q[a] = i - log.debug("bz.query: %s", q) - buglist = bz.query(q) - - elif action == 'new': - data = dict() - required=['product','component','version','short_desc','comment', - 'rep_platform','bug_severity','op_sys','bug_file_loc','priority'] - optional=['cc', 'blocked', 'dependson'] - for a in required + optional: - i = getattr(opt,a) - if i: - data[a] = i - for k in required: - if k not in data: - parser.error('Missing required argument: %s' % k) - log.debug("bz.createbug(%s)", data) - b = bz.createbug(**data) - buglist = [b] - - elif action == 'modify': - bugid_list = [] - for a in args: - if ',' in a: - for b in a.split(','): - bugid_list.append(b) - else: - bugid_list.append(a) - # Surely there's a simpler way to do that.. - # bail out if no bugs were given - if not bugid_list: - parser.error('No bug IDs given (maybe you forgot an argument somewhere?)') - # Iterate over a list of Bug objects - # FIXME: this should totally use some multicall magic - buglist = bz.getbugssimple(bugid_list) - for id,bug in zip(bugid_list,buglist): - if not bug: - log.error(" failed to load bug %s" % id) - continue - log.debug("modifying bug %s" % bug.bug_id) - if opt.comment: - log.debug(" add comment: %s" % opt.comment) - bug.addcomment(opt.comment) - if opt.close: - log.debug(" close %s" % opt.close) - bug.close(opt.close) - else: - print "Sorry - '%s' not implemented yet." % action - - # If we're doing new/query/modify, output our results - if action in ('new','query'): - if opt.outputformat: - format_field_re = re.compile("%{[a-z0-9_]+}") - def bug_field(matchobj): - fieldname = matchobj.group()[2:-1] - return str(getattr(b,fieldname)) - for b in buglist: - print format_field_re.sub(bug_field,opt.outputformat) - elif opt.output == 'ids': - for b in buglist: - print b.bug_id - elif opt.output == 'full': - fullbuglist = bz.getbugs([b.bug_id for b in buglist]) - for b in fullbuglist: - print b - if b.cc: print "CC: %s" % " ".join(b.cc) - if b.blocked: print "Blocked: %s" % " ".join([str(i) for i in b.blocked]) - if b.dependson: print "Depends: %s" % " ".join([str(i) for i in b.dependson]) - for c in b.longdescs: - print "* %s - %s (%s):\n%s\n" % (c['time'],c['name'],c['email'] or c['safe_email'],c['body']) - elif opt.output == 'normal': - for b in buglist: - print b - else: - parser.error("opt.output was set to something weird.") diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py new file mode 100644 index 0000000..564f00b --- /dev/null +++ b/bugzilla/__init__.py @@ -0,0 +1,74 @@ +# python-bugzilla - a Python interface to bugzilla using xmlrpclib. +# +# Copyright (C) 2007,2008 Red Hat Inc. +# Author: Will Woods <wwoods@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 bugzilla3 import Bugzilla3, Bugzilla32 +from rhbugzilla import RHBugzilla, RHBugzilla3 +from base import version +import xmlrpclib +import logging +log = logging.getLogger("bugzilla") + +def getBugzillaClassForURL(url): + log.debug("Choosing subclass for %s" % url) + s = xmlrpclib.ServerProxy(url) + rhbz = False + bzversion = '' + c = None + + # Check for a RH-only method + try: + log.debug("Checking for RH Bugzilla method bugzilla.getProdInfo()") + prodinfo = s.bugzilla.getProdInfo() + rhbz = True + except xmlrpclib.Fault: + pass + log.debug("rhbz=%s" % str(rhbz)) + + # Try to get the bugzilla version string + try: + log.debug("Checking return value of Buzilla.version()") + r = s.Bugzilla.version() + bzversion = r['version'] + except xmlrpclib.Fault: + pass + log.debug("bzversion='%s'" % str(bzversion)) + + # XXX note preference order: RHBugzilla* wins if available + # RH BZ 3.2 will have rhbz == True and bzversion == 3.1.x or 3.2.x. + if rhbz: + if bzversion.startswith('3.'): + c = RHBugzilla3 + else: + c = RHBugzilla + elif bzversion.startswith('3.'): + if bzversion.startswith('3.0'): + c = Bugzilla3 + else: # 3.1 or higher + c = Bugzilla32 + + return c + +class Bugzilla(object): + '''Magical Bugzilla class that figures out which Bugzilla implementation + to use and uses that. Requires 'url' parameter so we can check available + XMLRPC methods to determine the Bugzilla version.''' + def __init__(self,**kwargs): + log.info("Bugzilla v%s initializing" % base.version) + if 'url' in kwargs: + c = getBugzillaClassForURL(kwargs['url']) + if c: + self.__class__ = c + c.__init__(self,**kwargs) + log.info("Chose subclass %s v%s" % (c.__name__,c.version)) + else: + raise ValueError, "Couldn't determine Bugzilla version for %s" % kwargs['url'] + else: + raise TypeError, "You must pass a valid bugzilla xmlrpc.cgi URL" diff --git a/bugzilla/base.py b/bugzilla/base.py new file mode 100644 index 0000000..b316cac --- /dev/null +++ b/bugzilla/base.py @@ -0,0 +1,936 @@ +# base.py - the base classes etc. for a Python interface to bugzilla +# +# Copyright (C) 2007,2008 Red Hat Inc. +# Author: Will Woods <wwoods@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 xmlrpclib, urllib2 +try: + import cookielib +except ImportError: + import ClientCookie as cookielib +import os.path, base64 +import logging +log = logging.getLogger('bugzilla') + +version = '0.4' +user_agent = 'Python-urllib2/%s bugzilla.py/%s' % \ + (urllib2.__version__,version) + +def replace_getbug_errors_with_None(rawlist): + '''r is a raw xmlrpc response. + If it represents an error, None is returned. + Otherwise, r is returned. + This is mostly used for XMLRPC Multicall handling.''' + # Yes, this is a naive implementation + # XXX: return a generator? + result = [] + for r in rawlist: + if isinstance(r,dict) and 'bug_id' in r: + result.append(r) + else: + result.append(None) + return result + +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. + ''' + def __init__(self,**kwargs): + # Settings the user might want to tweak + self.user = '' + self.password = '' + self.url = '' + self.cookiefile = os.path.expanduser('~/.bugzillacookies') + self.user_agent = user_agent + self.logged_in = False + # Bugzilla object state info that users shouldn't mess with + self.init_private_data() + if 'url' in kwargs: + self.connect(kwargs['url']) + if 'user' in kwargs: + self.user = kwargs['user'] + if 'password' in kwargs: + self.password = kwargs['password'] + + def init_private_data(self): + '''initialize private variables used by this bugzilla instance.''' + self._cookiejar = None + self._proxy = None + self._transport = None + self._opener = None + self._querydata = None + self._querydefaults = None + self._products = None + self._bugfields = None + self._components = dict() + self._components_details = dict() + + #---- Methods for establishing bugzilla connection and logging in + + def initcookiefile(self,cookiefile=None): + '''Read the given (Mozilla-style) cookie file and fill in the + cookiejar, allowing us to use saved credentials to access Bugzilla. + If no file is given, self.cookiefile will be used.''' + if cookiefile: + self.cookiefile = cookiefile + cj = cookielib.MozillaCookieJar(self.cookiefile) + if os.path.exists(self.cookiefile): + cj.load() + else: + # Create an empty file that's only readable by this user + old_umask = os.umask(0077) + f = open(self.cookiefile,"w") + f.close() + os.umask(old_umask) + self._cookiejar = cj + self._cookiejar.filename = self.cookiefile + + configpath = ['/etc/bugzillarc','~/.bugzillarc'] + def readconfig(self,configpath=None): + '''Read bugzillarc file(s) into memory.''' + import ConfigParser + if not configpath: + configpath = self.configpath + configpath = [os.path.expanduser(p) for p in configpath] + c = ConfigParser.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(), lambda a,b: cmp(len(a),len(b)) or cmp(a,b)): + 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): + '''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. + ''' + # Set up the transport + self.initcookiefile() # sets _cookiejar + if url.startswith('https'): + self._transport = SafeCookieTransport() + else: + self._transport = CookieTransport() + self._transport.user_agent = self.user_agent + self._transport.cookiejar = self._cookiejar + # Set up the proxy, using the transport + self._proxy = xmlrpclib.ServerProxy(url,self._transport) + # Set up the urllib2 opener (using the same cookiejar) + handler = urllib2.HTTPCookieProcessor(self._cookiejar) + self._opener = urllib2.build_opener(handler) + self._opener.addheaders = [('User-agent',self.user_agent)] + self.url = url + self.readconfig() # we've changed URLs - reload config + 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.''' + self.init_private_data() # clears all the connection state + + # Note that the bugzilla methods will ignore an empty user/password if you + # send authentication info as a cookie in the request headers. So it's + # OK if we keep sending empty / bogus login info in other methods. + def _login(self,user,password): + '''IMPLEMENT ME: backend login method''' + raise NotImplementedError + + 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. + + 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: + r = self._login(self.user,self.password) + self.logged_in = True + log.info("login successful - dropping password from memory") + self.password = '' + except xmlrpclib.Fault, f: + r = False + return r + + def _logout(self): + '''IMPLEMENT ME: backend login method''' + raise NotImplementedError + + 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 + + #---- Methods and properties with basic bugzilla info + + def _getbugfields(self): + '''IMPLEMENT ME: Get bugfields from Bugzilla.''' + raise NotImplementedError + def _getqueryinfo(self): + '''IMPLEMENT ME: Get queryinfo from Bugzilla.''' + raise NotImplementedError + def _getproducts(self): + '''IMPLEMENT ME: Get product info from Bugzilla.''' + raise NotImplementedError + def _getcomponentsdetails(self,product): + '''IMPLEMENT ME: get component details for a product''' + raise NotImplementedError + def _getcomponents(self,product): + '''IMPLEMENT ME: Get component dict for a product''' + raise NotImplementedError + + 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 not self._bugfields: + try: + self._bugfields = self._getbugfields() + except xmlrpclib.Fault, f: + if f.faultCode == 'Client': + # okay, this instance doesn't have getbugfields. fine. + self._bugfields = [] + else: + # something bad actually happened on the server. blow up. + raise f + + return self._bugfields + bugfields = property(fget=lambda self: self.getbugfields(), + fdel=lambda self: setattr(self,'_bugfields',None)) + + def getqueryinfo(self,force_refresh=False): + '''Calls getQueryInfo, which returns a (quite large!) structure that + contains all of the query data and query defaults for the bugzilla + instance. Since this is a weighty call - takes a good 5-10sec on + bugzilla.redhat.com - we load the info in this private method and the + user instead plays with the querydata and querydefaults attributes of + the bugzilla object.''' + # Only fetch the data if we don't already have it, or are forced to + if force_refresh or not (self._querydata and self._querydefaults): + (self._querydata, self._querydefaults) = self._getqueryinfo() + # TODO: map _querydata to a dict, as with _components_details? + return (self._querydata, self._querydefaults) + # Set querydata and querydefaults as properties so they auto-create + # themselves when touched by a user. This bit was lifted from YumBase, + # because skvidal is much smarter than I am. + querydata = property(fget=lambda self: self.getqueryinfo()[0], + fdel=lambda self: setattr(self,"_querydata",None)) + querydefaults = property(fget=lambda self: self.getqueryinfo()[1], + fdel=lambda self: setattr(self,"_querydefaults",None)) + + def getproducts(self,force_refresh=False): + '''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() + return self._products + # Bugzilla.products is a property - we cache the product list on the first + # call and return it for each subsequent call. + products = property(fget=lambda self: self.getproducts(), + fdel=lambda self: setattr(self,'_products',None)) + def _product_id_to_name(self,productid): + '''Convert a product ID (int) to a product name (str).''' + # This will auto-create the 'products' list + for p in self.products: + if p['id'] == productid: + return p['name'] + 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'] + + 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] + # TODO - add a .components property that acts like a dict? + + 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, initialcclist''' + # XXX inconsistent: we don't do this list->dict mapping with querydata + if force_refresh or product not in self._components_details: + clist = self._getcomponentsdetails(product) + cdict = dict() + 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] + + #---- Methods for reading bugs and bug info + + def _getbug(self,id): + '''IMPLEMENT ME: Return a dict of full bug info for the given bug id''' + raise NotImplementedError + def _getbugs(self,idlist): + '''IMPLEMENT ME: Return a list of full bug dicts, one for each of the + given bug ids''' + raise NotImplementedError + def _getbugsimple(self,id): + '''IMPLEMENT ME: Return a short dict of simple bug info for the given + bug id''' + raise NotImplementedError + def _getbugssimple(self,idlist): + '''IMPLEMENT ME: Return a list of short bug dicts, one for each of the + given bug ids''' + raise NotImplementedError + def _query(self,query): + '''IMPLEMENT ME: Query bugzilla and return a list of matching bugs.''' + raise NotImplementedError + + # these return Bug objects + def getbug(self,id): + '''Return a Bug object with the full complement of bug data + already loaded.''' + log.debug("getbug(%i)" % id) + return Bug(bugzilla=self,dict=self._getbug(id)) + def getbugsimple(self,id): + '''Return a Bug object given bug id, populated with simple info''' + return Bug(bugzilla=self,dict=self._getbugsimple(id)) + def getbugs(self,idlist): + '''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.''' + return [(b and Bug(bugzilla=self,dict=b)) or None for b in self._getbugs(idlist)] + 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(bugzilla=self,dict=b)) or None for b in self._getbugssimple(idlist)] + 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) + return [Bug(bugzilla=self,dict=b) 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) + + #---- Methods for modifying existing bugs. + + # Most of these will probably also be available as Bug methods, e.g.: + # Bugzilla.setstatus(id,status) -> + # Bug.setstatus(status): self.bugzilla.setstatus(self.bug_id,status) + + # FIXME inconsistent method signatures + # FIXME add more comments on proper implementation + def _addcomment(self,id,comment,private=False, + timestamp='',worktime='',bz_gid=''): + '''IMPLEMENT ME: add a comment to the given bug ID''' + raise NotImplementedError + def _setstatus(self,id,status,comment='',private=False,private_in_it=False,nomail=False): + '''IMPLEMENT ME: Set the status of the given bug ID''' + raise NotImplementedError + def _closebug(self,id,resolution,dupeid,fixedin,comment,isprivate,private_in_it,nomail): + '''IMPLEMENT ME: close the given bug ID''' + raise NotImplementedError + def _setassignee(self,id,**data): + '''IMPLEMENT ME: set the assignee of the given bug ID''' + raise NotImplementedError + def _updatedeps(self,id,blocked,dependson,action): + '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. + blocked, dependson: list of bug ids/aliases + action: 'add' or 'delete' + ''' + raise NotImplementedError + def _updatecc(self,id,cclist,action,comment='',nomail=False): + '''IMPLEMENT ME: Update the CC list using the action and account list + specified. + cclist must be a list (not a tuple!) of addresses. + action may be 'add', 'delete', or 'overwrite'. + comment specifies an optional comment to add to the bug. + if mail is True, email will be generated for this change. + Note that using 'overwrite' may result in up to three XMLRPC calls + (fetch list, remove each element, add new elements). Avoid if possible. + ''' + raise NotImplementedError + def _updatewhiteboard(self,id,text,which,action): + '''IMPLEMENT ME: Update the whiteboard given by 'which' for the given + bug. performs the given action (which may be 'append',' prepend', or + 'overwrite') using the given text.''' + raise NotImplementedError + def _updateflags(self,id,flags): + '''Updates the flags associated with a bug report. + data should be a hash of {'flagname':'value'} pairs, like so: + {'needinfo':'?','fedora-cvs':'+'} + You may also add a "nomail":1 item, which will suppress email if set.''' + raise NotImplementedError + + #---- Methods for working with attachments + + def _attachment_encode(self,fh): + '''Return the contents of the file-like object fh in a form + appropriate for attaching to a bug in bugzilla. This is the default + encoding method, base64.''' + # Read data in chunks so we don't end up with two copies of the file + # in RAM. + chunksize = 3072 # base64 encoding wants input in multiples of 3 + data = '' + chunk = fh.read(chunksize) + while chunk: + # we could use chunk.encode('base64') but that throws a newline + # at the end of every output chunk, which increases the size of + # the output. + data = data + base64.b64encode(chunk) + chunk = fh.read(chunksize) + return data + + def _attachfile(self,id,**attachdata): + '''IMPLEMENT ME: attach a file to the given bug. + attachdata MUST contain the following keys: + data: File data, encoded in the bugzilla-preferred format. + attachfile() will encode it with _attachment_encode(). + description: Short description of this attachment. + filename: Filename for the attachment. + The following optional keys may also be added: + comment: An optional comment about this attachment. + isprivate: Set to True if the attachment should be marked private. + ispatch: Set to True if the attachment is a patch. + contenttype: 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 (attachment_id,mailresults). + ''' + raise NotImplementedError + + def attachfile(self,id,attachfile,description,**kwargs): + '''Attach a file to the given bug ID. Returns the ID of the attachment + or raises xmlrpclib.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: + filename: 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. + isprivate: Set to True if the attachment should be marked private. + ispatch: Set to True if the attachment is a patch. + contenttype: 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! + ''' + if isinstance(attachfile,str): + f = open(attachfile) + elif hasattr(attachfile,'read'): + f = attachfile + else: + raise TypeError, "attachfile must be filename or file-like object" + kwargs['description'] = description + if 'filename' not in kwargs: + kwargs['filename'] = os.path.basename(f.name) + # TODO: guess contenttype? + if 'contenttype' not in kwargs: + kwargs['contenttype'] = 'application/octet-stream' + kwargs['data'] = self._attachment_encode(f) + (attachid, mailresults) = self._attachfile(id,kwargs) + return attachid + + 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 + '?%i' % attachid + return att_uri + + def openattachment(self,attachid): + '''Get the contents of the attachment with the given attachment ID. + Returns a file-like object.''' + att_uri = self._attachment_uri(attachid) + att = urllib2.urlopen(att_uri) + # RFC 2183 defines the content-disposition header, if you're curious + disp = att.headers['content-disposition'].split(';') + [filename_parm] = [i for i in disp if i.strip().startswith('filename=')] + (dummy,filename) = filename_parm.split('=') + # RFC 2045/822 defines the grammar for the filename value, but + # I think we just need to remove the quoting. I hope. + att.name = filename.strip('"') + # Hooray, now we have a file-like object with .read() and .name + return att + + #---- createbug - big complicated call to create a new bug + + # Default list of required fields for createbug + createbug_required = ('product','component','version','short_desc','comment', + 'rep_platform','bug_severity','op_sys','bug_file_loc') + + def _createbug(self,**data): + '''IMPLEMENT ME: Raw xmlrpc call for createBug() + Doesn't bother guessing defaults or checking argument validity. + Returns bug_id''' + raise NotImplementedError + + def createbug(self,check_args=False,**data): + '''Create a bug with the given info. Returns a new Bug object. + data should be given as keyword args - remember that you can also + populate a dict and call createbug(**dict) to fill in keyword args. + The arguments are as follows. Note that some are optional and some + are required. + + "product" => "<Product Name>", + # REQUIRED Name of Bugzilla product. + # Ex: Red Hat Enterprise Linux + "component" => "<Component Name>", + # REQUIRED Name of component in Bugzilla product. + # Ex: anaconda + "version" => "<Version of Product>", + # REQUIRED Version in the list for the Bugzilla product. + # Ex: 4.5 + # versions are listed in querydata['product'][<product>]['versions'] + "rep_platform" => "<Platform>", + # REQUIRED Valid architecture from the rep_platform list. + # Ex: i386 + # See querydefaults['rep_platform_list'] for accepted values. + "bug_severity" => "medium", + # REQUIRED Valid severity from the list of severities. + # See querydefaults['bug_severity_list'] for accepted values. + "op_sys" => "Linux", + # REQUIRED Operating system bug occurs on. + # See querydefaults['op_sys_list'] for accepted values. + "bug_file_loc" => "http://", + # REQUIRED URL to additional information for bug report. + # Ex: http://people.redhat.com/dkl + "short_desc" => "<Brief text about bug>", + # REQUIRED One line summary describing the bug report. + "comment" => "<More Detailed Description>", + # REQUIRED A detail descript about the bug report. + + "alias" => "<Bug Alias>", + # OPTIONAL Will give the bug an alias name. + # Alias can't be merely numerical. + # Alias can't contain spaces or commas. + # Alias can't be more than 20 chars long. + # Alias has to be unique. + "assigned_to" => "<Bugzilla Account>", + # OPTIONAL Will be determined by component owner otherwise. + "reporter" => "<Bugzilla Account>", + # OPTIONAL Will use current login if blank. + "qa_contact" => "<Bugzilla Account>", + # OPTIONAL Will be determined by component qa_contact otherwise. + "cc" => "<Comma/Space separated list>", + # OPTIONAL Space or Comma separated list of Bugzilla accounts. + "priority" => "urgent", + # OPTIONAL Valid priority from the list of priorities. + # Ex: medium + # See querydefaults['priority_list'] for accepted values. + "bug_status" => 'NEW', + # OPTIONAL Status to place the new bug in. + # Default: NEW + "blocked" => '', + # OPTIONAL Comma or space separate list of bug id's + # this report blocks. + "dependson" => '', + # OPTIONAL Comma or space separate list of bug id's + # this report depends on. + ''' + # The xmlrpc will raise an error if one of these is missing, but + # let's try to save a network roundtrip here if possible.. + for i in self.createbug_required: + if i not in data or not data[i]: + if i == 'bug_file_loc': + data[i] = 'http://' + else: + raise TypeError, "required field missing or empty: '%s'" % i + # Sort of a chicken-and-egg problem here - check_args will save you a + # network roundtrip if your op_sys or rep_platform is bad, but at the + # expense of getting querydefaults, which is.. an added network + # roundtrip. Basically it's only useful if you're mucking around with + # createbug() in ipython and you've already loaded querydefaults. + if check_args: + if data['op_sys'] not in self.querydefaults['op_sys_list']: + raise ValueError, "invalid value for op_sys: %s" % data['op_sys'] + if data['rep_platform'] not in self.querydefaults['rep_platform_list']: + raise ValueError, "invalid value for rep_platform: %s" % data['rep_platform'] + # Actually perform the createbug call. + # We return a nearly-empty Bug object, which is kind of a bummer 'cuz + # it'll take another network roundtrip to fill it. We *could* fake it + # and fill in the blanks with the data given to this method, but the + # server might modify/add/drop stuff. Then we'd have a Bug object that + # lied about the actual contents of the database. That would be bad. + bug_id = self._createbug(**data) + return Bug(self,bug_id=bug_id) + # Trivia: this method has ~5.8 lines of comment per line of code. Yow! + +class CookieResponse: + '''Fake HTTPResponse object that we can fill with headers we got elsewhere. + We can then pass it to CookieJar.extract_cookies() to make it pull out the + cookies from the set of headers we have.''' + def __init__(self,headers): + self.headers = headers + #log.debug("CookieResponse() headers = %s" % headers) + def info(self): + return self.headers + +class CookieTransport(xmlrpclib.Transport): + '''A subclass of xmlrpclib.Transport that supports cookies.''' + cookiejar = None + scheme = 'http' + + # Cribbed from xmlrpclib.Transport.send_user_agent + def send_cookies(self, connection, cookie_request): + if self.cookiejar is None: + log.debug("send_cookies(): creating in-memory cookiejar") + self.cookiejar = cookielib.CookieJar() + elif self.cookiejar: + log.debug("send_cookies(): using existing cookiejar") + # Let the cookiejar figure out what cookies are appropriate + log.debug("cookie_request headers currently: %s" % cookie_request.header_items()) + self.cookiejar.add_cookie_header(cookie_request) + log.debug("cookie_request headers now: %s" % cookie_request.header_items()) + # Pull the cookie headers out of the request object... + cookielist=list() + for h,v in cookie_request.header_items(): + if h.startswith('Cookie'): + log.debug("sending cookie: %s=%s" % (h,v)) + cookielist.append([h,v]) + # ...and put them over the connection + for h,v in cookielist: + connection.putheader(h,v) + else: + log.debug("send_cookies(): cookiejar empty. Nothing to send.") + + # This is the same request() method from xmlrpclib.Transport, + # with a couple additions noted below + def request(self, host, handler, request_body, verbose=0): + h = self.make_connection(host) + if verbose: + h.set_debuglevel(1) + + # ADDED: construct the URL and Request object for proper cookie handling + request_url = "%s://%s%s" % (self.scheme,host,handler) + log.debug("request_url is %s" % request_url) + cookie_request = urllib2.Request(request_url) + + self.send_request(h,handler,request_body) + self.send_host(h,host) + self.send_cookies(h,cookie_request) # ADDED. creates cookiejar if None. + self.send_user_agent(h) + self.send_content(h,request_body) + + errcode, errmsg, headers = h.getreply() + + # ADDED: parse headers and get cookies here + cookie_response = CookieResponse(headers) + # Okay, extract the cookies from the headers + self.cookiejar.extract_cookies(cookie_response,cookie_request) + log.debug("cookiejar now contains: %s" % self.cookiejar._cookies) + # And write back any changes + if hasattr(self.cookiejar,'save'): + try: + self.cookiejar.save(self.cookiejar.filename) + except e: + log.error("Couldn't write cookiefile %s: %s" % \ + (self.cookiejar.filename,str(e))) + + if errcode != 200: + raise xmlrpclib.ProtocolError( + host + handler, + errcode, errmsg, + headers + ) + + self.verbose = verbose + + try: + sock = h._conn.sock + except AttributeError: + sock = None + + return self._parse_response(h.getfile(), sock) + +class SafeCookieTransport(xmlrpclib.SafeTransport,CookieTransport): + '''SafeTransport subclass that supports cookies.''' + scheme = 'https' + request = CookieTransport.request + +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. + autorefresh - automatically refresh the data in this bug after calling + a method that modifies the bug. Defaults to True. You can + call refresh() to do this manually. + ''' + def __init__(self,bugzilla,**kwargs): + self.bugzilla = bugzilla + self.autorefresh = True + if 'dict' in kwargs and kwargs['dict']: + log.debug("Bug(%s)" % kwargs['dict'].keys()) + self.__dict__.update(kwargs['dict']) + if 'bug_id' in kwargs: + log.debug("Bug(%i)" % kwargs['bug_id']) + setattr(self,'bug_id',kwargs['bug_id']) + if 'autorefresh' in kwargs: + self.autorefresh = kwargs['autorefresh'] + # No bug_id? this bug is invalid! + if not hasattr(self,'bug_id'): + if hasattr(self,'id'): + self.bug_id = self.id + else: + raise TypeError, "Bug object needs a bug_id" + + self.url = bugzilla.url.replace('xmlrpc.cgi', + 'show_bug.cgi?id=%i' % self.bug_id) + + # TODO: set properties for missing bugfields + # The problem here is that the property doesn't know its own name, + # otherwise we could just do .refresh() and return __dict__[f] after. + # basically I need a suicide property that can replace itself after + # it's called. Or something. + #for f in bugzilla.bugfields: + # if f in self.__dict__: continue + # setattr(self,f,property(fget=lambda self: self.refresh())) + + def __str__(self): + '''Return a simple string representation of this bug''' + # XXX Not really sure why we get short_desc sometimes and + # short_short_desc other times. I feel like I'm working around + # a bug here, so keep an eye on this. + if 'short_short_desc' in self.__dict__: + desc = self.short_short_desc + elif 'short_desc' in self.__dict__: + desc = self.short_desc + elif 'summary' in self.__dict__: + desc = self.summary + else: + log.warn("Weird; this bug has no summary?") + desc = "[ERROR: SUMMARY MISSING]" + log.debug(self.__dict__) + # Some BZ3 implementations give us an ID instead of a name. + if 'assigned_to' not in self.__dict__: + if 'assigned_to_id' in self.__dict__: + self.assigned_to = self.bugzilla._getuserforid(self.assigned_to_id) + return "#%-6s %-10s - %s - %s" % (self.bug_id,self.bug_status, + self.assigned_to,desc) + def __repr__(self): + return '<Bug #%i on %s at %#x>' % (self.bug_id,self.bugzilla.url, + id(self)) + + def __getattr__(self,name): + if 'bug_id' in self.__dict__: + if self.bugzilla.bugfields and name not in self.bugzilla.bugfields: + # We have a list of fields, and you ain't on it. Bail out. + raise AttributeError, "field %s not in bugzilla.bugfields" % name + #print "Bug %i missing %s - loading" % (self.bug_id,name) + self.refresh() + if name in self.__dict__: + return self.__dict__[name] + raise AttributeError, "Bug object has no attribute '%s'" % name + + def refresh(self): + '''Refresh all the data in this Bug.''' + r = self.bugzilla._getbug(self.bug_id) + self.__dict__.update(r) + + def reload(self): + '''An alias for reload()''' + self.refresh() + + def setstatus(self,status,comment='',private=False,private_in_it=False,nomail=False): + '''Update the status for this bug report. + Valid values for status are listed in querydefaults['bug_status_list'] + Commonly-used values are ASSIGNED, MODIFIED, and NEEDINFO. + To change bugs to CLOSED, use .close() instead. + See Bugzilla._setstatus() for details.''' + self.bugzilla._setstatus(self.bug_id,status,comment,private,private_in_it,nomail) + # TODO reload bug data here? + + def setassignee(self,assigned_to='',reporter='',qa_contact='',comment=''): + '''Set any of the assigned_to, reporter, or qa_contact fields to a new + bugzilla account, with an optional comment, e.g. + setassignee(reporter='sadguy@brokencomputer.org', + assigned_to='wwoods@redhat.com') + setassignee(qa_contact='wwoods@redhat.com',comment='wwoods QA ftw') + You must set at least one of the three assignee fields, or this method + will throw a ValueError. + Returns [bug_id, mailresults].''' + if not (assigned_to or reporter or qa_contact): + # XXX is ValueError the right thing to throw here? + raise ValueError, "You must set one of assigned_to, reporter, or qa_contact" + # empty fields are ignored, so it's OK to send 'em + r = self.bugzilla._setassignee(self.bug_id,assigned_to=assigned_to, + reporter=reporter,qa_contact=qa_contact,comment=comment) + # TODO reload bug data here? + return r + def addcomment(self,comment,private=False,timestamp='',worktime='',bz_gid=''): + '''Add the given comment to this bug. Set private to True to mark this + comment as private. You can also set a timestamp for the comment, in + "YYYY-MM-DD HH:MM:SS" form. Worktime is undocumented upstream. + If bz_gid is set, and the entire bug is not already private to that + group, this comment will be private.''' + self.bugzilla._addcomment(self.bug_id,comment,private,timestamp, + worktime,bz_gid) + # TODO reload bug data here? + def close(self,resolution,dupeid=0,fixedin='',comment='',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. + If you want to suppress sending out mail for this bug closing, set + nomail=True. + ''' + self.bugzilla._closebug(self.bug_id,resolution,dupeid,fixedin, + comment,isprivate,private_in_it,nomail) + # TODO reload bug data here? + def _dowhiteboard(self,text,which,action): + '''Actually does the updateWhiteboard call to perform the given action + (append,prepend,overwrite) with the given text on the given whiteboard + for the given bug.''' + self.bugzilla._updatewhiteboard(self.bug_id,text,which,action) + # TODO reload bug data here? + + def getwhiteboard(self,which='status'): + '''Get the current value of the whiteboard specified by 'which'. + Known whiteboard names: 'status','internal','devel','qa'. + Defaults to the 'status' whiteboard.''' + return getattr(self,"%s_whiteboard" % which) + def appendwhiteboard(self,text,which='status'): + '''Append the given text (with a space before it) to the given + whiteboard. Defaults to using status_whiteboard.''' + self._dowhiteboard(text,which,'append') + def prependwhiteboard(self,text,which='status'): + '''Prepend the given text (with a space following it) to the given + whiteboard. Defaults to using status_whiteboard.''' + self._dowhiteboard(text,which,'prepend') + def setwhiteboard(self,text,which='status'): + '''Overwrites the contents of the given whiteboard with the given text. + Defaults to using status_whiteboard.''' + self._dowhiteboard(text,which,'overwrite') + 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) + tags.remove(tag) + self.setwhiteboard(' '.join(tags),which) + def addcc(self,cclist,comment=''): + '''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''' + self.bugzilla.updatecc(self.bug_id,cclist,'add',comment) + def deletecc(self,cclist,comment=''): + '''Removes the given email addresses from the CC list for this bug.''' + self.bugzilla.updatecc(self.bug_id,cclist,'delete',comment) +# TODO: attach(file), getflag(), setflag() +# TODO: add a sync() method that writes the changed data in the Bug object +# back to Bugzilla? diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py new file mode 100644 index 0000000..41a3440 --- /dev/null +++ b/bugzilla/bugzilla3.py @@ -0,0 +1,139 @@ +# bugzilla3.py - a Python interface to Bugzilla 3.x using xmlrpclib. +# +# Copyright (C) 2008 Red Hat Inc. +# Author: Will Woods <wwoods@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 bugzilla.base + +class Bugzilla3(bugzilla.base.BugzillaBase): + '''Concrete implementation of the Bugzilla protocol. This one uses the + methods provided by standard Bugzilla 3.0.x releases.''' + + version = '0.1' + user_agent = bugzilla.base.user_agent + ' Bugzilla3/%s' % version + + def __init__(self,**kwargs): + bugzilla.base.BugzillaBase.__init__(self,**kwargs) + self.user_agent = self.__class__.user_agent + + 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() + + #---- Methods and properties with basic bugzilla info + + def _getuserforid(self,userid): + '''Get the username for the given userid''' + # STUB FIXME + return str(userid) + + # Connect the backend methods to the XMLRPC methods + def _getbugfields(self): + '''Get a list of valid fields for bugs.''' + # XXX BZ3 doesn't currently provide anything like the getbugfields() + # method, so we fake it by looking at bug #1. Yuck. + keylist = self._getbug(1).keys() + if 'assigned_to' not in keylist: + keylist.append('assigned_to') + return keylist + def _getqueryinfo(self): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + def _getproducts(self): + '''This throws away a bunch of data that RH's getProdInfo + didn't return. Ah, abstraction.''' + product_ids = self._proxy.Product.get_accessible_products() + r = self._proxy.Product.get_products(product_ids) + return r['products'] + 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): + raise NotImplementedError + + #---- Methods for reading bugs and bug info + + def _getbugs(self,idlist): + '''Return a list of dicts of full bug info for each given bug id''' + r = self._proxy.Bug.get_bugs({'ids':idlist}) + return [i['internals'] for i in r['bugs']] + def _getbug(self,id): + '''Return a dict of full bug info for the given bug id''' + return self._getbugs([id])[0] + # Bugzilla3 doesn't have getbugsimple - alias to the full method(s) + _getbugsimple = _getbug + _getbugssimple = _getbugs + + # Bugzilla 3.0 doesn't have a *lot* of things, actually. + def _query(self,query): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + def _addcomment(self,id,comment,private=False, + timestamp='',worktime='',bz_gid=''): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + def _setstatus(self,id,status,comment='',private=False,private_in_it=False,nomail=False): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + def _closebug(self,id,resolution,dupeid,fixedin,comment,isprivate,private_in_it,nomail): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + def _setassignee(self,id,**data): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + def _updatedeps(self,id,deplist): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + def _updatecc(self,id,cclist,action,comment='',nomail=False): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + def _updatewhiteboard(self,id,text,which,action): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + # TODO: update this when the XMLRPC interface grows requestee support + def _updateflags(self,id,flags): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + def _attachfile(self,id,**attachdata): + raise NotImplementedError, "Bugzilla 3.0 does not support this method." + + #---- createbug - call to create a new bug + + createbug_required = ('product','component','summary','version', + 'op_sys','platform') + def _createbug(self,**data): + '''Raw xmlrpc call for createBug() Doesn't bother guessing defaults + or checking argument validity. Use with care. + Returns bug_id''' + r = self._proxy.Bug.create(data) + return r['id'] + +# Bugzilla 3.2 adds some new goodies on top of Bugzilla3. +# Well, okay. It adds one new goodie. +class Bugzilla32(Bugzilla3): + '''Concrete implementation of the Bugzilla protocol. This one uses the + methods provided by standard Bugzilla 3.2.x releases. + + For further information on the methods defined here, see the API docs: + http://www.bugzilla.org/docs/3.2/en/html/api/ + ''' + + version = '0.1' + user_agent = bugzilla.base.user_agent + ' Bugzilla32/%s' % version + + def _addcomment(self,id,comment,private=False, + timestamp='',worktime='',bz_gid=''): + '''Add a comment to the bug with the given ID. Other optional + arguments are as follows: + private: if True, mark this comment as private. + timestamp: Ignored by BZ32. + worktime: amount of time spent on this comment, in hours + bz_gid: Ignored by BZ32. + ''' + return self._proxy.Bug.add_comment({'id':id, + 'comment':comment, + 'private':private, + 'work_time':worktime}) + diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py new file mode 100644 index 0000000..47a3e22 --- /dev/null +++ b/bugzilla/rhbugzilla.py @@ -0,0 +1,503 @@ +# rhbugzilla.py - a Python interface to Red Hat Bugzilla using xmlrpclib. +# +# Copyright (C) 2008 Red Hat Inc. +# Author: Will Woods <wwoods@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 bugzilla.base +from bugzilla3 import Bugzilla32 +import copy, xmlrpclib + +class RHBugzilla(bugzilla.base.BugzillaBase): + '''Concrete implementation of the Bugzilla protocol. This one uses the + methods provided by Red Hat's Bugzilla 2.18 variant. + + RHBugzilla supports XMLRPC MultiCall. The methods which start with a + single underscore are thin wrappers around XMLRPC methods and should thus + be safe for multicall use. + + Documentation for most of these methods can be found here: + https://bugzilla.redhat.com/docs/en/html/api/extensions/compat_xmlrpc/code/webservice.html + ''' + + version = '0.2' + user_agent = bugzilla.base.user_agent + ' RHBugzilla/%s' % version + + def __init__(self,**kwargs): + bugzilla.base.BugzillaBase.__init__(self,**kwargs) + self.user_agent = self.__class__.user_agent + + def _login(self,user,password): + '''Backend login method for RHBugzilla.''' + return self._proxy.bugzilla.login(user,password) + + def _logout(self): + '''Backend logout method for RHBugzilla.''' + # "Logouts are not implemented due to the non-session nature of + # XML-RPC communication." + # That's funny, since we get a (session-based) login cookie... + return True + + #---- Methods and properties with basic bugzilla info + + def _multicall(self): + '''This returns kind of a mash-up of the Bugzilla object and the + xmlrpclib.MultiCall object. Methods you call on this object will be + added to the MultiCall queue, but they will return None. When you're + ready, call the run() method and all the methods in the queue will be + run and the results of each will be returned in a list. So, for example: + + mc = bz._multicall() + mc._getbug(1) + mc._getbug(1337) + mc._query({'component':'glibc','product':'Fedora','version':'devel'}) + (bug1, bug1337, queryresult) = mc.run() + + Note that you should only use the raw xmlrpc calls (mostly the methods + starting with an underscore). Normal getbug(), for example, tries to + return a Bug object, but with the multicall object it'll end up empty + and, therefore, useless. + + Further note that run() returns a list of raw xmlrpc results; you'll + need to wrap the output in Bug objects yourself if you're doing that + kind of thing. For example, Bugzilla.getbugs() could be implemented: + + mc = self._multicall() + for id in idlist: + mc._getbug(id) + rawlist = mc.run() + return [Bug(self,dict=b) for b in rawlist] + ''' + mc = copy.copy(self) + mc._proxy = xmlrpclib.MultiCall(self._proxy) + def run(): return mc._proxy().results + mc.run = run + return mc + + # Connect the backend methods to the XMLRPC methods + def _getbugfields(self): + return self._proxy.bugzilla.getBugFields() + def _getqueryinfo(self): + return self._proxy.bugzilla.getQueryInfo() + def _getproducts(self): + '''Backend _getproducts method for RH Bugzilla. This predates the + Bugzilla3 Products stuff, so we need to massage this data to make it + fit the proper format''' + r = self._proxy.bugzilla.getProdInfo() + n = 0 + prod = [] + for name,desc in r.iteritems(): + # We're making up a fake id, since RHBugzilla doesn't use them + prod.append({'id':n,'name':name,'description':desc}) + n += 1 + return prod + def _getcomponents(self,product): + if type(product) == int: + product = self._product_id_to_name(product) + return self._proxy.bugzilla.getProdCompInfo(product) + def _getcomponentsdetails(self,product): + if type(product) == int: + product = self._product_id_to_name(product) + return self._proxy.bugzilla.getProdCompDetails(product) + def _get_info(self,product=None): + '''This is a convenience method that does getqueryinfo, getproducts, + and (optionally) getcomponents in one big fat multicall. This is a bit + faster than calling them all separately. + + If you're doing interactive stuff you should call this, with the + appropriate product name, after connecting to Bugzilla. This will + cache all the info for you and save you an ugly delay later on.''' + mc = self._multicall() + mc._getqueryinfo() + mc._getproducts() + mc._getbugfields() + if product: + mc._getcomponents(product) + mc._getcomponentsdetails(product) + r = mc.run() + (self._querydata,self._querydefaults) = r.pop(0) + self._products = r.pop(0) + self._bugfields = r.pop(0) + if product: + self._components[product] = r.pop(0) + self._components_details[product] = r.pop(0) + + #---- Methods for reading bugs and bug info + + def _getbug(self,id): + '''Return a dict of full bug info for the given bug id''' + return self._proxy.bugzilla.getBug(id) + def _getbugsimple(self,id): + '''Return a short dict of simple bug info for the given bug id''' + r = self._proxy.bugzilla.getBugSimple(id) + if r and 'bug_id' not in r: + # XXX hurr. getBugSimple doesn't fault if the bug is missing. + # Let's synthesize one ourselves. + raise xmlrpclib.Fault("Server","Could not load bug %s" % id) + else: + return r + # Multicall methods + def _getbugs(self,idlist): + '''Like _getbug, but takes a list of ids and returns a corresponding + list of bug objects. Uses multicall for awesome speed.''' + mc = self._multicall() + for id in idlist: + mc._getbug(id) + raw_results = mc.run() + del mc + # check results for xmlrpc errors, and replace them with None + return bugzilla.base.replace_getbug_errors_with_None(raw_results) + def _getbugssimple(self,idlist): + '''Like _getbugsimple, but takes a list of ids and returns a + corresponding list of bug objects. Uses multicall for awesome speed.''' + mc = self._multicall() + for id in idlist: + mc._getbugsimple(id) + raw_results = mc.run() + del mc + # check results for xmlrpc errors, and replace them with None + return bugzilla.base.replace_getbug_errors_with_None(raw_results) + + 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 dict like this: {'bugs':buglist, + 'displaycolumns':columnlist, + 'sql':querystring} + buglist is a list of dicts describing bugs. You can specify which + columns/keys will be listed in the bugs by setting 'column_list' in + the query; otherwise the default columns are used (see the list in + querydefaults['default_column_list']). The list of columns will be + in 'displaycolumns', and the SQL query used by this query will be in + 'sql'. + ''' + return self._proxy.bugzilla.runQuery(query) + + #---- Methods for modifying existing bugs. + + # Most of these will probably also be available as Bug methods, e.g.: + # Bugzilla.setstatus(id,status) -> + # Bug.setstatus(status): self.bugzilla.setstatus(self.bug_id,status) + + def _addcomment(self,id,comment,private=False, + timestamp='',worktime='',bz_gid=''): + '''Add a comment to the bug with the given ID. Other optional + arguments are as follows: + private: if True, mark this comment as private. + timestamp: comment timestamp, in the form "YYYY-MM-DD HH:MM:SS" + worktime: amount of time spent on this comment (undoc in upstream) + bz_gid: if present, and the entire bug is *not* already private + to this group ID, this comment will be marked private. + ''' + return self._proxy.bugzilla.addComment(id,comment,self.user,'', + private,timestamp,worktime,bz_gid) + def _setstatus(self,id,status,comment='',private=False,private_in_it=False,nomail=False): + '''Set the status of the bug with the given ID. You may optionally + include a comment to be added, and may further choose to mark that + comment as private. + The status may be anything from querydefaults['bug_status_list']. + Common statuses: 'NEW','ASSIGNED','MODIFIED','NEEDINFO' + Less common: 'VERIFIED','ON_DEV','ON_QA','REOPENED' + 'CLOSED' is not valid with this method; use closebug() instead. + ''' + return self._proxy.bugzilla.changeStatus(id,status,self.user,'', + comment,private,private_in_it,nomail) + def _closebug(self,id,resolution,dupeid,fixedin,comment,isprivate,private_in_it,nomail): + '''Raw xmlrpc call for closing bugs. Documentation from Bug.pm is + below. Note that we drop the username and password fields because the + Bugzilla object contains them already. + + closeBug($bugid, $new_resolution, $username, $password, $dupeid, + $new_fixed_in, $comment, $isprivate, $private_in_it, $nomail) + + Close a current Bugzilla bug report with a specific resolution. This will eventually be done in Bugzilla/Bug.pm + instead and is meant to only be a quick fix. Please use bugzilla.changesStatus to changed to an opened state. + This method will change the bug report's status to CLOSED. + + $bugid + # ID of bug report to add comment to. + $new_resolution + # Valid Bugzilla resolution to transition the report into. + # DUPLICATE requires $dupeid to be passed in. + $dupeid + # Bugzilla report ID that this bug is being closed as + # duplicate of. + # Requires $new_resolution to be DUPLICATE. + $new_fixed_in + # OPTIONAL String representing version of product/component + # that bug is fixed in. + $comment + # OPTIONAL Text string containing comment to add. + $isprivate + # OPTIONAL Whether the comment will be private to the + # 'private_comment' Bugzilla group. + # Default: false + $private_in_it + # OPTIONAL if true will make the comment private in + # Issue Tracker + # Default: follows $isprivate + $nomail + # OPTIONAL Flag that is either 1 or 0 if you want email to be sent or not for this change + ''' + return self._proxy.bugzilla.closeBug(id,resolution,self.user,'', + dupeid,fixedin,comment,isprivate,private_in_it,nomail) + def _setassignee(self,id,**data): + '''Raw xmlrpc call to set one of the assignee fields on a bug. + changeAssignment($id, $data, $username, $password) + data: 'assigned_to','reporter','qa_contact','comment' + returns: [$id, $mailresults]''' + return self._proxy.bugzilla.changeAssignment(id,data) + def _updatedeps(self,id,blocked,dependson,action): + '''update the deps (blocked/dependson) for the given bug. + blocked/dependson: list of bug ids/aliases + action: 'add' or 'delete' + + RHBZ call: + updateDepends($bug_id,$data,$username,$password,$nodependencyemail) + #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove') + + RHBZ only does one bug at a time, so this method will loop through + the blocked/dependson lists. This may be slow. + ''' + r = [] + # Massage input to match what RHBZ expects + if action == 'delete': + action == 'remove' + data = {'id':id, 'action':action, 'blocked':'', 'dependson':''} + for b in blocked: + data['blocked'] = b + self._proxy.bugzilla.updateDepends(id,data) + data['blocked'] = '' + for d in dependson: + data['dependson'] = d + self._proxy.bugzilla.updateDepends(id,data) + def _updatecc(self,id,cclist,action,comment='',nomail=False): + '''Updates the CC list using the action and account list specified. + cclist must be a list (not a tuple!) of addresses. + action may be 'add', 'delete', or 'overwrite'. + comment specifies an optional comment to add to the bug. + if mail is True, email will be generated for this change. + ''' + # Massage the 'action' param into what the old updateCC call expects + if action == 'delete': + action = 'remove' + elif action == 'overwrite': + action = 'makeexact' + data = {'id':id, 'action':action, 'cc':','.join(cclist), + 'comment':comment, 'nomail':nomail} + return self._proxy.bugzilla.updateCC(data) + def _updatewhiteboard(self,id,text,which,action): + '''Update the whiteboard given by 'which' for the given bug. + performs the given action (which may be 'append',' prepend', or + 'overwrite') using the given text.''' + data = {'type':which,'text':text,'action':action} + return self._proxy.bugzilla.updateWhiteboard(id,data) + # TODO: update this when the XMLRPC interface grows requestee support + def _updateflags(self,id,flags): + '''Updates the flags associated with a bug report. + data should be a hash of {'flagname':'value'} pairs, like so: + {'needinfo':'?','fedora-cvs':'+'} + You may also add a "nomail":1 item, which will suppress email if set. + + NOTE: the Red Hat XMLRPC interface does not yet support setting the + requestee (as in: needinfo from smartguy@answers.com). Alas.''' + return self._proxy.bugzilla.updateFlags(id,flags) + + #---- Methods for working with attachments + + # If your bugzilla wants attachments in something other than base64, you + # should override _attachment_encode here. + # If your bugzilla uses non-standard paths for attachment.cgi, you'll + # want to override _attachment_uri here. + + def _attachfile(self,id,**attachdata): + return self._proxy.bugzilla.addAttachment(id,attachdata) + + #---- createbug - call to create a new bug + + def _createbug(self,**data): + '''Raw xmlrpc call for createBug() Doesn't bother guessing defaults + or checking argument validity. Use with care. + Returns bug_id''' + r = self._proxy.bugzilla.createBug(data) + return r[0] + +class RHBugzilla3(Bugzilla32, RHBugzilla): + '''Concrete implementation of the Bugzilla protocol. This one uses the + methods provided by Red Hat's Bugzilla 3.2+ instance, which is a superset + of the Bugzilla 3.2 methods. The additional methods (Bug.search, Bug.update) + should make their way into a later upstream Bugzilla release (probably 4.0). + + Note that RHBZ3 *also* supports most of the old RHBZ methods, under the + 'bugzilla' namespace, so we use those when BZ3 methods aren't available. + + This class was written using bugzilla.redhat.com's API docs: + https://bugzilla.redhat.com/docs/en/html/api/ + + By default, _getbugs will multicall Bug.get(id) multiple times, rather than + doing a single Bug.get(idlist) call. You can disable this behavior by + setting the 'multicall' property to False. This is faster, but less + compatible with RHBugzilla. + ''' + + version = '0.1' + user_agent = bugzilla.base.user_agent + ' RHBugzilla3/%s' % version + + def __init__(self,**kwargs): + Bugzilla32.__init__(self,**kwargs) + self.user_agent = self.__class__.user_agent + self.multicall = kwargs.get('multicall',True) + + # XXX it'd be nice if this wasn't just a copy of RHBugzilla's _getbugs + def _getbugs(self,idlist): + r = [] + if self.multicall: + mc = self._multicall() + for id in idlist: + mc._proxy.bugzilla.getBug(id) + raw_results = mc.run() + del mc + # check results for xmlrpc errors, and replace them with None + r = bugzilla.base.replace_getbug_errors_with_None(raw_results) + else: + raw_results = self._proxy.Bug.get({'ids':idlist}) + r = [i['internals'] for i in raw_results['bugs']] + return r + + 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']. + You can also pass in keys called 'quicksearch' or 'savedsearch' - + 'quicksearch' will do a quick keyword search like the simple search + on the Bugzilla home page. + 'savedsearch' should be the name of a previously-saved search to + execute. You need to be logged in for this to work. + Returns a dict like this: {'bugs':buglist, + 'sql':querystring} + buglist is a list of dicts describing bugs, and 'sql' contains the SQL + generated by executing the search. + ''' + return self._proxy.Bug.search(query) + + #---- Methods for updating bugs. + + def _update_bugs(self,ids,updates): + '''Update the given fields with the given data in one or more bugs. + ids should be a list of integers or strings, representing bug ids or + aliases. + updates is a dict containing pairs like so: {'fieldname':'newvalue'} + ''' + # TODO document changeable fields & return values + # TODO I think we need to catch XMLRPC exceptions to get a useful + # return value + return self._proxy.Bug.update({'ids':ids,'updates':updates}) + + def _update_bug(self,id,updates): + '''Update a single bug, specified by integer ID or (string) bug alias. + Really just a convenience method for _update_bugs(ids=[id],updates)''' + return self._update_bugs(ids=[id],updates=updates) + + # Eventually - when RHBugzilla is well and truly obsolete - we'll delete + # all of these methods and refactor the Base Bugzilla object so all the bug + # modification calls go through _update_bug. + # Until then, all of these methods are basically just wrappers around it. + + # TODO: allow multiple bug IDs + + def _setstatus(self,id,status,comment='',private=False,private_in_it=False,nomail=False): + '''Set the status of the bug with the given ID.''' + update={'bug_status':status} + if comment: + update['comment'] = comment + return self._update_bug(id,update) + + def _closebug(self,id,resolution,dupeid,fixedin,comment,isprivate,private_in_it,nomail): + '''Close the given bug. This is the raw call, and no data checking is + done here. That's up to the closebug method. + Note that the private_in_it and nomail args are ignored.''' + update={'bug_status':'CLOSED','resolution':resolution} + if dupeid: + update['resolution'] = 'DUPLICATE' + update['dupe_id'] = dupeid + if fixedin: + update['fixed_in'] = fixedin + if comment: + update['comment'] = comment + if isprivate: + update['commentprivacy'] = True + return self._update_bug(id,update) + + def _setassignee(self,id,**data): + '''Raw xmlrpc call to set one of the assignee fields on a bug. + changeAssignment($id, $data, $username, $password) + data: 'assigned_to','reporter','qa_contact','comment' + returns: [$id, $mailresults]''' + # drop empty items + update = dict([(k,v) for k,v in data.iteritems() if v != '']) + return self._update_bug(id,update) + + def _updatedeps(self,id,blocked,dependson,action): + '''Update the deps (blocked/dependson) for the given bug. + blocked, dependson: list of bug ids/aliases + action: 'add' or 'delete' + ''' + if action not in ('add','delete'): + raise ValueError, "action must be 'add' or 'delete'" + update={'%s_blocked' % action: blocked, + '%s_dependson' % action: dependson} + self._update_bug(id,update) + + def _updatecc(self,id,cclist,action,comment='',nomail=False): + '''Updates the CC list using the action and account list specified. + cclist must be a list (not a tuple!) of addresses. + action may be 'add', 'delete', or 'overwrite'. + comment specifies an optional comment to add to the bug. + if mail is True, email will be generated for this change. + ''' + update = {} + if comment: + update['comment'] = comment + + if action in ('add','delete'): + update['%s_cc' % action] = cclist + self._update_bug(id,update) + elif action == 'overwrite': + r = self._getbug(id) + if 'cc' not in r: + raise AttributeError, "Can't find cc list in bug %s" % str(id) + self._updatecc(id,r['cc'],'delete') + self._updatecc(id,cclist,'add') + # XXX we don't check inputs on other backend methods, maybe this + # is more appropriate in the public method(s) + else: + raise ValueError, "action must be 'add','delete', or 'overwrite'" + + def _updatewhiteboard(self,id,text,which,action): + '''Update the whiteboard given by 'which' for the given bug. + performs the given action (which may be 'append',' prepend', or + 'overwrite') using the given text. + + RHBZ3 Bug.update() only supports overwriting, so append/prepend + may cause two server roundtrips - one to fetch, and one to update. + ''' + if not which.endswith('_whiteboard'): + which = which + '_whiteboard' + update = {} + if action == 'overwrite': + update[which] = text + else: + r = self._getbug(id) + if which not in r: + raise ValueError, "No such whiteboard %s in bug %s" % \ + (which,str(id)) + wb = r[which] + if action == 'prepend': + update[which] = text+' '+wb + elif action == 'append': + update[which] = wb+' '+text + self._update_bug(id,update) |