diff options
Diffstat (limited to 'bugzilla/rhbugzilla.py')
-rw-r--r-- | bugzilla/rhbugzilla.py | 503 |
1 files changed, 503 insertions, 0 deletions
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) |