More multicall cleanups
[opensuse:python-bugzilla.git] / bugzilla.py
1 #!/usr/bin/python
2 # bugzilla.py - a Python interface to bugzilla.redhat.com, using xmlrpclib.
3 #
4 # Copyright (C) 2007 Red Hat Inc.
5 # Author: Will Woods <wwoods@redhat.com>
6
7 # This program is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by the
9 # Free Software Foundation; either version 2 of the License, or (at your
10 # option) any later version.  See http://www.gnu.org/copyleft/gpl.html for
11 # the full text of the license.
12
13 import xmlrpclib, urllib2, cookielib
14 import os.path, base64, copy
15
16 version = '0.2'
17 user_agent = 'bugzilla.py/%s (Python-urllib2/%s)' % \
18         (version,urllib2.__version__)
19
20 class Bugzilla(object):
21     '''An object which represents the data and methods exported by a Bugzilla
22     instance. Uses xmlrpclib to do its thing. You'll want to create one thusly:
23     bz=Bugzilla(url='https://bugzilla.redhat.com/xmlrpc.cgi',user=u,password=p)
24
25     If you so desire, you can use cookie headers for authentication instead.
26     So you could do:
27     cf=glob(os.path.expanduser('~/.mozilla/firefox/default.*/cookies.txt'))
28     bz=Bugzilla(url=url,cookies=cf)
29     and, assuming you have previously logged info bugzilla with firefox, your
30     pre-existing auth cookie would be used, thus saving you the trouble of
31     stuffing your username and password in the bugzilla call.
32     On the other hand, this currently munges up the cookie so you'll have to
33     log back in when you next use bugzilla in firefox. So this is not
34     currently recommended.
35
36     The methods which start with a single underscore are thin wrappers around
37     xmlrpc calls; those should be safe for multicall usage.
38     '''
39     def __init__(self,**kwargs):
40         # Settings the user might want to tweak
41         self.user       = ''
42         self.password   = ''
43         self.url        = ''
44         # Bugzilla object state info that users shouldn't mess with
45         self._cookiejar  = None
46         self._proxy      = None
47         self._opener     = None
48         self._querydata  = None
49         self._querydefaults = None
50         self._products   = None 
51         self._components = dict()
52         if 'cookies' in kwargs:
53             self.__readcookiefile(kwargs['cookies'])
54         if 'url' in kwargs:
55             self.connect(kwargs['url'])
56         if 'user' in kwargs:
57             self.user = kwargs['user']
58         if 'password' in kwargs:
59             self.password = kwargs['password']
60
61     #---- Methods for establishing bugzilla connection and logging in
62
63     def __readcookiefile(self,cookiefile):
64         '''Read the given (Mozilla-style) cookie file and fill in the cookiejar,
65         allowing us to use the user's saved credentials to access bugzilla.'''
66         cj = cookielib.MozillaCookieJar()
67         cj.load(cookiefile)
68         self._cookiejar = cj
69         self._cookiejar.filename = cookiefile
70
71     def connect(self,url):
72         '''Connect to the bugzilla instance with the given url.'''
73         # Set up the transport
74         if url.startswith('https'):
75             self._transport = SafeCookieTransport()
76         else:
77             self._transport = CookieTransport() 
78         self._transport.user_agent = user_agent
79         self._transport.cookiejar = self._cookiejar or cookielib.CookieJar()
80         # Set up the proxy, using the transport
81         self._proxy = xmlrpclib.ServerProxy(url,self._transport)
82         # Set up the urllib2 opener (using the same cookiejar)
83         handler = urllib2.HTTPCookieProcessor(self._cookiejar)
84         self._opener = urllib2.build_opener(handler)
85         self._opener.addheaders = [('User-agent',user_agent)]
86         self.url = url
87
88     # Note that the bugzilla methods will ignore an empty user/password if you
89     # send authentication info as a cookie in the request headers. So it's
90     # OK if we keep sending empty / bogus login info in other methods.
91     def login(self,user,password):
92         '''Attempt to log in using the given username and password. Subsequent
93         method calls will use this username and password. Returns False if 
94         login fails, otherwise returns a dict of user info.
95         
96         Note that it is not required to login before calling other methods;
97         you may just set user and password and call whatever methods you like.
98         '''
99         self.user = user
100         self.password = password
101         try: 
102             r = self._proxy.bugzilla.login(self.user,self.password)
103         except xmlrpclib.Fault, f:
104             r = False
105         return r
106
107     #---- Methods and properties with basic bugzilla info 
108
109     def _multicall(self):
110         '''This returns kind of a mash-up of the Bugzilla object and the 
111         xmlrpclib.MultiCall object. Methods you call on this object will be added
112         to the MultiCall queue, but they will return None. When you're ready, call
113         the run() method and all the methods in the queue will be run and the
114         results of each will be returned in a list. So, for example:
115
116         mc = bz._multicall()
117         mc._getbug(1)
118         mc._getbug(1337)
119         mc._query({'component':'glibc','product':'Fedora','version':'devel'})
120         (bug1, bug1337, queryresult) = mc.run()
121     
122         Note that you should only use the raw xmlrpc calls (mostly the methods
123         starting with an underscore). Normal getbug(), for example, tries to
124         return a Bug object, but with the multicall object it'll end up empty
125         and, therefore, useless.
126
127         Further note that run() returns a list of raw xmlrpc results; you'll
128         need to wrap the output in Bug objects yourself if you're doing that
129         kind of thing. For example, Bugzilla.getbugs() could be implemented:
130
131         mc = self._multicall()
132         for id in idlist:
133             mc._getbug(id)
134         rawlist = mc.run()
135         return [Bug(self,dict=b) for b in rawlist]
136         '''
137         mc = copy.copy(self)
138         mc._proxy = xmlrpclib.MultiCall(self._proxy)
139         def run(): return mc._proxy().results
140         mc.run = run
141         return mc
142
143     def _getqueryinfo(self):
144         return self._proxy.bugzilla.getQueryInfo(self.user,self.password)
145     def getqueryinfo(self,force_refresh=False):
146         '''Calls getQueryInfo, which returns a (quite large!) structure that
147         contains all of the query data and query defaults for the bugzilla
148         instance. Since this is a weighty call - takes a good 5-10sec on
149         bugzilla.redhat.com - we load the info in this private method and the
150         user instead plays with the querydata and querydefaults attributes of
151         the bugzilla object.'''
152         # Only fetch the data if we don't already have it, or are forced to
153         if force_refresh or not (self._querydata and self._querydefaults):
154             (self._querydata, self._querydefaults) = self._getqueryinfo()
155         return (self._querydata, self._querydefaults)
156     # Set querydata and querydefaults as properties so they auto-create
157     # themselves when touched by a user. This bit was lifted from YumBase,
158     # because skvidal is much smarter than I am.
159     querydata = property(fget=lambda self: self.getqueryinfo()[0],
160                          fdel=lambda self: setattr(self,"_querydata",None))
161     querydefaults = property(fget=lambda self: self.getqueryinfo()[1],
162                          fdel=lambda self: setattr(self,"_querydefaults",None))
163
164     def _getproducts(self):
165         return self._proxy.bugzilla.getProdInfo(self.user, self.password)
166     def getproducts(self,force_refresh=False):
167         '''Return a dict of product names and product descriptions.'''
168         if force_refresh or not self._products:
169             self._products = self._getproducts()
170         return self._products
171     # Bugzilla.products is a property - we cache the product list on the first
172     # call and return it for each subsequent call.
173     products = property(fget=lambda self: self.getproducts(),
174                         fdel=lambda self: setattr(self,'_products',None))
175
176     def _getcomponents(self,product):
177             return self._proxy.bugzilla.getProdCompInfo(product, 
178                                         self.user,self.password)
179     def getcomponents(self,product,force_refresh=False):
180         '''Return a dict of components for the given product.'''
181         if force_refresh or product not in self._components:
182             self._components[product] = self._getcomponents(product)
183         return self._components[product]
184     # TODO - add a .components property that acts like a dict?
185
186     def _get_info(self,product=None):
187         '''This is a convenience method that does getqueryinfo, getproducts,
188         and (optionally) getcomponents in one big fat multicall. This is much
189         faster than calling them all separately.
190         
191         If you're doing interactive stuff you should call this, with the
192         appropriate product name, after connecting to Bugzilla. This will
193         cache all the info for you and save you an ugly delay later on.'''
194         mc = self._multicall()
195         mc._getqueryinfo()
196         mc._getproducts()
197         if product:
198             mc._getcomponents(product)
199         r = mc.run()
200         (self._querydata,self._querydefaults) = r[0]
201         self._products = r[1]
202         if product:
203             self._components[product] = r[2]
204         # In theory, there should be some way to set a variable on Bug
205         # such that it contains attributes for all the keys listed in the
206         # getBug call.  This isn't it, though.
207         #{'methodName':'bugzilla.getBug','params':(1,self.user,self.password)}
208         #Bug.__slots__ = r[3].keys()
209                  
210
211     #---- Methods for reading bugs and bug info
212
213     # Return raw dicts
214     def _getbug(self,id):
215         '''Return a dict of full bug info for the given bug id'''
216         return self._proxy.bugzilla.getBug(id, self.user, self.password)
217     def _getbugsimple(self,id):
218         '''Return a short dict of simple bug info for the given bug id'''
219         return self._proxy.bugzilla.getBugSimple(id, self.user, self.password)
220     def _getbugs(self,idlist):
221         '''Like _getbug, but takes a list of ids and returns a corresponding
222         list of bug objects. Uses multicall for awesome speed.'''
223         mc = self._multicall()
224         for id in idlist:
225             mc._getbug(id)
226         return mc.run()
227         # I sure hope mc gets garbage-collected here.
228     def _getbugssimple(self,idlist):
229         '''Like _getbugsimple, but takes a list of ids and returns a
230         corresponding list of bug objects. Uses multicall for awesome speed.'''
231         mc = self._multicall()
232         for id in idlist:
233             mc._getbugsimple(id)
234         return mc.run()
235     def _query(self,query):
236         '''Query bugzilla and return a list of matching bugs.
237         query must be a dict with fields like those in in querydata['fields'].
238
239         Returns a dict like this: {'bugs':buglist,
240                                    'displaycolumns':columnlist,
241                                    'sql':querystring}
242         
243         buglist is a list of dicts describing bugs. You can specify which 
244         columns/keys will be listed in the bugs by setting 'column_list' in
245         the query; otherwise the default columns are used (see the list in
246         querydefaults['default_column_list']). The list of columns will be
247         in 'displaycolumns', and the SQL query used by this query will be in
248         'sql'. 
249         ''' 
250         return self._proxy.bugzilla.runQuery(query,self.user,self.password)
251
252     # these return Bug objects 
253     def getbug(self,id):
254         '''Return a Bug object with the full complement of bug data
255         already loaded.'''
256         return Bug(bugzilla=self,dict=self._getbug(id))
257     def getbugsimple(self,id):
258         '''Return a Bug object given bug id, populated with simple info'''
259         return Bug(bugzilla=self,dict=self._getbugsimple(id))
260     def getbugs(self,idlist):
261         '''Return a list of Bug objects with the full complement of bug data
262         already loaded.'''
263         return [Bug(bugzilla=self,dict=b) for b in self._getbugs(idlist)]
264     def getbugssimple(self,idlist):
265         '''Return a list of Bug objects for the given bug ids, populated with
266         simple info'''
267         return [Bug(bugzilla=self,dict=b) for b in self._getbugssimple(idlist)]
268     def query(self,query):
269         '''Query bugzilla and return a list of matching bugs.
270         query must be a dict with fields like those in in querydata['fields'].
271
272         Returns a list of Bug objects.
273         '''
274         r = self._query(query)
275         return [Bug(bugzilla=self,dict=b) for b in r['bugs']]
276
277     def query_comments(self,product,version,component,string,matchtype='allwordssubstr'):
278         '''Convenience method - query for bugs filed against the given
279         product, version, and component whose comments match the given string.
280         matchtype specifies the type of match to be done. matchtype may be
281         any of the types listed in querydefaults['long_desc_type_list'], e.g.:
282         ['allwordssubstr','anywordssubstr','substring','casesubstring',
283          'allwords','anywords','regexp','notregexp']
284         Return value is the same as with query().
285         '''
286         q = {'product':product,'version':version,'component':component,
287              'long_desc':string,'long_desc_type':matchtype}
288         return self.query(q)
289
290     #---- Methods for modifying existing bugs.
291
292     # Most of these will probably also be available as Bug methods, e.g.:
293     # Bugzilla.setstatus(id,status) ->
294     #   Bug.setstatus(status): self.bugzilla.setstatus(self.bug_id,status)
295
296     def _addcomment(self,id,comment,private=False,
297                    timestamp='',worktime='',bz_gid=''):
298         '''Add a comment to the bug with the given ID. Other optional 
299         arguments are as follows:
300             private:   if True, mark this comment as private.
301             timestamp: comment timestamp, in the form "YYYY-MM-DD HH:MM:SS"
302             worktime:  amount of time spent on this comment (undoc in upstream)
303             bz_gid:    if present, and the entire bug is *not* already private
304                        to this group ID, this comment will be marked private.
305         '''
306         return self._proxy.bugzilla.addComment(id,comment,
307                    self.user,self.password,private,timestamp,worktime,bz_gid)
308     
309     def _setstatus(self,id,status,comment='',private=False):
310         '''Set the status of the bug with the given ID. You may optionally
311         include a comment to be added, and may further choose to mark that
312         comment as private.
313         The status may be anything from querydefaults['bug_status_list'].
314         Common statuses: 'NEW','ASSIGNED','MODIFIED','NEEDINFO'
315         Less common: 'VERIFIED','ON_DEV','ON_QA','REOPENED'
316         'CLOSED' is not valid with this method; use closebug() instead.
317         '''
318         return self._proxy.bugzilla.setstatus(id,status,
319                 self.user,self.password,comment,private)
320
321     def _closebug(self,id):
322         #closeBug($bugid, $new_resolution, $username, $password, $dupeid, $new_fixed_in, $comment, $isprivate)
323         # if new_resolution is 'DUPLICATE', dupeid is not optional
324         # new_fixed_id, comment, isprivate are optional
325         raise NotImplementedError
326
327     def _setassignee(self,id,assignee):
328         #changeAssignment($id, $data, $username, $password)
329         #data: 'assigned_to','reporter','qa_contact','comment'
330         #returns: [$id, $mailresults]
331         raise NotImplementedError
332
333     def _updatedeps(self,id,deplist):
334         #updateDepends($bug_id,$data,$username,$password,$nodependencyemail)
335         #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')
336         raise NotImplementedError
337
338     def _updatecc(self,id,cclist,action,comment='',nomail=False):
339         '''Updates the CC list using the action and account list specified.
340         action may be 'add', 'remove', or 'makeexact'.
341         comment specifies an optional comment to add to the bug.
342         if mail is True, email will be generated for this change.
343         '''
344         data = {'id':id, 'action':action, 'cc':','.join(cclist),
345                 'comment':comment, 'nomail':nomail}
346         return self._proxy.bugzilla.updateCC(data,self.user,self.password)
347
348     def _updatewhiteboard(self,id,text,which,action):
349         '''Update the whiteboard given by 'which' for the given bug.
350         performs the given action (which may be 'append',' prepend', or 
351         'overwrite') using the given text.'''
352         data = {'type':which,'text':text,'action':action}
353         return self._proxy.bugzilla.updateWhiteboard(id,data,self.user,self.password)
354
355     # TODO: flag handling?
356
357     #---- Methods for working with attachments
358
359     def __attachment_encode(self,fh):
360         '''Return the contents of the file-like object fh in a form
361         appropriate for attaching to a bug in bugzilla.'''
362         # Read data in chunks so we don't end up with two copies of the file
363         # in RAM.
364         chunksize = 3072 # base64 encoding wants input in multiples of 3
365         data = ''
366         chunk = fh.read(chunksize)
367         while chunk:
368             # we could use chunk.encode('base64') but that throws a newline
369             # at the end of every output chunk, which increases the size of
370             # the output.
371             data = data + base64.b64encode(chunk)
372             chunk = fh.read(chunksize)
373         return data
374
375     def attachfile(self,id,attachfile,description,**kwargs):
376         '''Attach a file to the given bug ID. Returns the ID of the attachment
377         or raises xmlrpclib.Fault if something goes wrong.
378         attachfile may be a filename (which will be opened) or a file-like
379         object, which must provide a 'read' method. If it's not one of these,
380         this method will raise a TypeError.
381         description is the short description of this attachment.
382         Optional keyword args are as follows:
383             filename:  this will be used as the filename for the attachment.
384                        REQUIRED if attachfile is a file-like object with no
385                        'name' attribute, otherwise the filename or .name
386                        attribute will be used.
387             comment:   An optional comment about this attachment.
388             isprivate: Set to True if the attachment should be marked private.
389             ispatch:   Set to True if the attachment is a patch.
390             contenttype: The mime-type of the attached file. Defaults to
391                          application/octet-stream if not set. NOTE that text
392                          files will *not* be viewable in bugzilla unless you 
393                          remember to set this to text/plain. So remember that!
394         '''
395         if isinstance(attachfile,str):
396             f = open(attachfile)
397         elif hasattr(attachfile,'read'):
398             f = attachfile
399         else:
400             raise TypeError, "attachfile must be filename or file-like object"
401         kwargs['description'] = description
402         if 'filename' not in kwargs:
403             kwargs['filename'] = os.path.basename(f.name)
404         # TODO: guess contenttype?
405         if 'contenttype' not in kwargs:
406             kwargs['contenttype'] = 'application/octet-stream'
407         kwargs['data'] = self.__attachment_encode(f)
408         (attachid, mailresults) = server._proxy.bugzilla.addAttachment(id,kwargs,self.user,self.password)
409         return attachid
410
411     def openattachment(self,attachid):
412         '''Get the contents of the attachment with the given attachment ID.
413         Returns a file-like object.'''
414         att_uri = self._url.replace('xmlrpc.cgi','attachment.cgi')
415         att_uri = att_uri + '?%i' % attachid
416         att = urllib2.urlopen(att_uri)
417         # RFC 2183 defines the content-disposition header, if you're curious
418         disp = att.headers['content-disposition'].split(';')
419         [filename_parm] = [i for i in disp if i.strip().startswith('filename=')]
420         (dummy,filename) = filename_parm.split('=')
421         # RFC 2045/822 defines the grammar for the filename value, but
422         # I think we just need to remove the quoting. I hope.
423         att.name = filename.strip('"')
424         # Hooray, now we have a file-like object with .read() and .name
425         return att
426
427     #---- createbug - big complicated call to create a new bug
428
429     def createbug(self,**kwargs):
430         '''Create a bug with the given info. Returns the bug ID.'''
431         raise NotImplementedError
432
433
434 class CookieTransport(xmlrpclib.Transport):
435     '''A subclass of xmlrpclib.Transport that supports cookies.'''
436     cookiejar = None
437     scheme = 'http'
438
439     # Cribbed from xmlrpclib.Transport.send_user_agent 
440     def send_cookies(self, connection, cookie_request):
441         if self.cookiejar is None:
442             self.cookiejar = cookielib.CookieJar()
443         elif self.cookiejar:
444             # Let the cookiejar figure out what cookies are appropriate
445             self.cookiejar.add_cookie_header(cookie_request)
446             # Pull the cookie headers out of the request object...
447             cookielist=list()
448             for h,v in cookie_request.header_items():
449                 if h.startswith('Cookie'):
450                     cookielist.append([h,v])
451             # ...and put them over the connection
452             for h,v in cookielist:
453                 connection.putheader(h,v)
454
455     # This is the same request() method from xmlrpclib.Transport,
456     # with a couple additions noted below
457     def request(self, host, handler, request_body, verbose=0):
458         h = self.make_connection(host)
459         if verbose:
460             h.set_debuglevel(1)
461
462         # ADDED: construct the URL and Request object for proper cookie handling
463         request_url = "%s://%s/" % (self.scheme,host)
464         cookie_request  = urllib2.Request(request_url) 
465
466         self.send_request(h,handler,request_body)
467         self.send_host(h,host) 
468         self.send_cookies(h,cookie_request) # ADDED. creates cookiejar if None.
469         self.send_user_agent(h)
470         self.send_content(h,request_body)
471
472         errcode, errmsg, headers = h.getreply()
473
474         # ADDED: parse headers and get cookies here
475         # fake a response object that we can fill with the headers above
476         class CookieResponse:
477             def __init__(self,headers): self.headers = headers
478             def info(self): return self.headers
479         cookie_response = CookieResponse(headers)
480         # Okay, extract the cookies from the headers
481         self.cookiejar.extract_cookies(cookie_response,cookie_request)
482         # And write back any changes
483         if hasattr(self.cookiejar,'save'):
484             self.cookiejar.save(self.cookiejar.filename)
485
486         if errcode != 200:
487             raise xmlrpclib.ProtocolError(
488                 host + handler,
489                 errcode, errmsg,
490                 headers
491                 )
492
493         self.verbose = verbose
494
495         try:
496             sock = h._conn.sock
497         except AttributeError:
498             sock = None
499
500         return self._parse_response(h.getfile(), sock)
501
502 class SafeCookieTransport(xmlrpclib.SafeTransport,CookieTransport):
503     '''SafeTransport subclass that supports cookies.'''
504     scheme = 'https'
505     request = CookieTransport.request
506
507 class Bug(object):
508     '''A container object for a bug report. Requires a Bugzilla instance - 
509     every Bug is on a Bugzilla, obviously.
510     Optional keyword args:
511         dict=DICT - populate attributes with the result of a getBug() call
512         bug_id=ID - if dict does not contain bug_id, this is required before
513                     you can read any attributes or make modifications to this
514                     bug.
515     Note that modifying a Bug will not update the data attributes - you can 
516     call refresh() to do this.
517     '''
518     # TODO: Implement an 'autorefresh' attribute that causes update methods
519     # to run as multicalls which fetch the newly-updated data afterward.
520     def __init__(self,bugzilla,**kwargs):
521         self.bugzilla = bugzilla
522         if 'dict' in kwargs and kwargs['dict']:
523             self.__dict__.update(kwargs['dict'])
524         if 'bug_id' in kwargs:
525             setattr(self,'bug_id',kwargs['bug_id'])
526
527     def __str__(self):
528         '''Return a simple string representation of this bug'''
529         if 'short_short_desc' in self.__dict__:
530             desc = self.short_short_desc
531         else:
532             desc = self.short_desc
533         return "#%-6s %-10s - %s - %s" % (self.bug_id,self.bug_status,
534                                           self.assigned_to,desc)
535
536     def __getattr__(self,name):
537         if name not in ('__members__','__methods__','trait_names',
538                 '_getAttributeNames') and not name.endswith(')'):
539             # FIXME: that .endswith hack is an extremely stupid way to figure
540             # out if we're checking on a method call. Find a smarter one!
541             if not 'bug_id' in self.__dict__:
542                 raise AttributeError
543             #print "Bug %i missing %s - loading" % (self.bug_id,name)
544             self.refresh()
545             if name in self.__dict__:
546                 return self.__dict__[name]
547         raise AttributeError
548
549     def refresh(self):
550         '''Refresh all the data in this Bug.'''
551         r = self.bugzilla._getbug(self.bug_id)
552         self.__dict__.update(r)
553
554     def _dowhiteboard(self,text,which,action):
555         '''Actually does the updateWhiteboard call to perform the given action
556         (append,prepend,overwrite) with the given text on the given whiteboard
557         for the given bug.'''
558         self.bugzilla._updatewhiteboard(self.bug_id,text,which,action)
559     def getwhiteboard(self,which='status'):
560         '''Get the current value of the whiteboard specified by 'which'.
561         Known whiteboard names: 'status','internal','devel','qa'.
562         Defaults to the 'status' whiteboard.
563         '''
564         return getattr(self,"%s_whiteboard" % which)
565     def appendwhiteboard(self,text,which='status'):
566         '''Append the given text (with a space before it) to the given 
567         whiteboard. Defaults to using status_whiteboard.'''
568         self._dowhiteboard(text,which,'append')
569     def prependwhiteboard(self,text,which='status'):
570         '''Prepend the given text (with a space following it) to the given
571         whiteboard. Defaults to using status_whiteboard.'''
572         self._dowhiteboard(text,which,'prepend')
573     def setwhiteboard(self,text,which='status'):
574         '''Overwrites the contents of the given whiteboard with the given text.
575         Defaults to using status_whiteboard.'''
576         self._dowhiteboard(text,which,'overwrite')
577     def addtag(self,tag,which='status'):
578         '''Adds the given tag to the given bug.'''
579         whiteboard = self.getwhiteboard(which)
580         if whiteboard:
581             self.appendwhiteboard(tag,which)
582         else:
583             self.setwhiteboard(tag,which)
584     def gettags(self,which='status'):
585         '''Get a list of tags (basically just whitespace-split the given
586         whiteboard)'''
587         return self.getwhiteboard(which).split()
588     def deltag(self,tag,which='status'):
589         '''Removes the given tag from the given bug.'''
590         tags = self.gettags(which)
591         tags.remove(tag)
592         self.setwhiteboard(' '.join(tags),which)
593 # TODO: add a sync() method that writes the changed data in the Bug object
594 # back to Bugzilla. Someday.