summaryrefslogtreecommitdiffstats
path: root/bugzilla
diff options
context:
space:
mode:
authorWill Woods <wwoods@redhat.com>2008-09-04 14:04:44 -0400
committerWill Woods <wwoods@redhat.com>2008-09-04 14:04:44 -0400
commitbcb002af27ee4a224be7578ded886471231e598e (patch)
treeda21e70d6243862417b99a7efff1786340ae64e9 /bugzilla
parent041a27dbc66b007f2e4c7132c229f14af5163982 (diff)
parentce753c44a0445b796cd19851cba8dff2e5d455d0 (diff)
downloadpython-bugzilla-bcb002af27ee4a224be7578ded886471231e598e.tar.gz
python-bugzilla-bcb002af27ee4a224be7578ded886471231e598e.tar.xz
python-bugzilla-bcb002af27ee4a224be7578ded886471231e598e.zip
Merge from abstractify branch
Diffstat (limited to 'bugzilla')
-rwxr-xr-xbugzilla306
-rw-r--r--bugzilla/__init__.py74
-rw-r--r--bugzilla/base.py936
-rw-r--r--bugzilla/bugzilla3.py139
-rw-r--r--bugzilla/rhbugzilla.py503
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)