From 091e7386f730c17961e8c3b4d80fcf667d29d718 Mon Sep 17 00:00:00 2001 From: Will Woods Date: Tue, 25 Mar 2008 17:30:30 -0400 Subject: Woo doggies a big chunk of abstractification. --- MANIFEST.in | 2 +- README | 11 +- TODO | 8 +- bin/bugzilla | 302 +++++++++++++++++ bugzilla | 302 ----------------- bugzilla.py | 887 ------------------------------------------------- bugzilla/__init__.py | 45 +++ bugzilla/base.py | 843 ++++++++++++++++++++++++++++++++++++++++++++++ bugzilla/bugzilla3.py | 189 +++++++++++ bugzilla/rhbugzilla.py | 189 +++++++++++ setup.py | 8 +- 11 files changed, 1583 insertions(+), 1203 deletions(-) create mode 100755 bin/bugzilla delete mode 100755 bugzilla delete mode 100644 bugzilla.py create mode 100644 bugzilla/__init__.py create mode 100644 bugzilla/base.py create mode 100644 bugzilla/bugzilla3.py create mode 100644 bugzilla/rhbugzilla.py diff --git a/MANIFEST.in b/MANIFEST.in index 9aca33d..ba370ea 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include COPYING TODO selftest.py MANIFEST.in +include COPYING TODO README selftest.py MANIFEST.in diff --git a/README b/README index 99e399a..202e41c 100644 --- a/README +++ b/README @@ -3,18 +3,19 @@ over XMLRPC. Currently it targets Red Hat's xmlrpc services, because: a) That's what the Fedora project uses, and -b) Even if it is ugly, it's got more methods than the Bugzilla 3.0 API +b) Even if it is ugly, it's got more methods than the Bugzilla 3.x API -In the near future (see TODO) we may support the Bugzilla 3.0 API, although -it probably won't not support all the same methods as the RHBugzilla class. +In the near future (see TODO) we may fully support the Bugzilla 3.x API, +although it still probably won't not support all the same methods as the +RHBugzilla class. In the long-term future, Red Hat is planning on porting their interfaces to the Bugzilla 3.0 framework and contributing them to upstream Bugzilla, so in time we may drop the Red Hat implementation in favor of one unified Bugzilla -interface. Won't that be nice? +interface. Won't that be nice? Comments, suggestions, and - most of all - patches are welcomed and encouraged. Enjoy. -Will Woods , 7 Sep 2007 +Will Woods , 25 Mar 2008 diff --git a/TODO b/TODO index f09ae8b..4d1b6a9 100644 --- a/TODO +++ b/TODO @@ -1,4 +1,4 @@ -- Bugzilla class should be renamed RHBugzilla -- Create a Bugzilla3 class that uses the Bugzilla3.0 web services -- connect method should move out of Bugzilla class, return one of the two - above classes (depending on what type of bugzilla instance we're talking to) +- flesh out Bugzilla3 class +- better documentation for abstract methods in BugzillaBase +- more consistent calls for abstract methods +- make the abstract methods return stuff closer to Bugzilla3's return values diff --git a/bin/bugzilla b/bin/bugzilla new file mode 100755 index 0000000..caeda15 --- /dev/null +++ b/bin/bugzilla @@ -0,0 +1,302 @@ +#!/usr/bin/python +# bugzilla - a commandline frontend for the python bugzilla module +# +# Copyright (C) 2007 Red Hat Inc. +# Author: Will Woods +# +# 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") + # 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'] + 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 b/bugzilla deleted file mode 100755 index caeda15..0000000 --- a/bugzilla +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/python -# bugzilla - a commandline frontend for the python bugzilla module -# -# Copyright (C) 2007 Red Hat Inc. -# Author: Will Woods -# -# 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") - # 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'] - 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.py b/bugzilla.py deleted file mode 100644 index fe597e2..0000000 --- a/bugzilla.py +++ /dev/null @@ -1,887 +0,0 @@ -# bugzilla.py - a Python interface to bugzilla.redhat.com, using xmlrpclib. -# -# Copyright (C) 2007 Red Hat Inc. -# Author: Will Woods -# -# 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, cookielib -import os.path, base64, copy - -version = '0.3' -user_agent = 'bugzilla.py/%s (Python-urllib2/%s)' % \ - (version,urllib2.__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 Bugzilla(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) - - If you so desire, you can use cookie headers for authentication instead. - So you could do: - cf=glob(os.path.expanduser('~/.mozilla/firefox/default.*/cookies.txt')) - bz=Bugzilla(url=url,cookies=cf) - and, assuming you have previously logged info bugzilla with firefox, your - pre-existing auth cookie would be used, thus saving you the trouble of - stuffing your username and password in the bugzilla call. - On the other hand, this currently munges up the cookie so you'll have to - log back in when you next use bugzilla in firefox. So this is not - currently recommended. - - The methods which start with a single underscore are thin wrappers around - xmlrpc calls; those should be safe for multicall usage. - ''' - def __init__(self,**kwargs): - # Settings the user might want to tweak - self.user = '' - self.password = '' - self.url = '' - # Bugzilla object state info that users shouldn't mess with - self._cookiejar = None - self._proxy = None - self._opener = None - self._querydata = None - self._querydefaults = None - self._products = None - self._bugfields = None - self._components = dict() - self._components_details = dict() - if 'cookies' in kwargs: - self.readcookiefile(kwargs['cookies']) - if 'url' in kwargs: - self.connect(kwargs['url']) - if 'user' in kwargs: - self.user = kwargs['user'] - if 'password' in kwargs: - self.password = kwargs['password'] - - #---- Methods for establishing bugzilla connection and logging in - - def readcookiefile(self,cookiefile): - '''Read the given (Mozilla-style) cookie file and fill in the cookiejar, - allowing us to use the user's saved credentials to access bugzilla.''' - cj = cookielib.MozillaCookieJar() - cj.load(cookiefile) - self._cookiejar = cj - self._cookiejar.filename = cookiefile - - def connect(self,url): - '''Connect to the bugzilla instance with the given url.''' - # Set up the transport - if url.startswith('https'): - self._transport = SafeCookieTransport() - else: - self._transport = CookieTransport() - self._transport.user_agent = user_agent - self._transport.cookiejar = self._cookiejar or cookielib.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',user_agent)] - self.url = url - - # 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): - '''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 a dict of user info. - - Note that it is not required to login before calling other methods; - you may just set user and password and call whatever methods you like. - ''' - self.user = user - self.password = password - try: - r = self._proxy.bugzilla.login(self.user,self.password) - except xmlrpclib.Fault, f: - r = False - return r - - #---- 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 - - def _getbugfields(self): - return self._proxy.bugzilla.getBugFields(self.user,self.password) - 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): - return self._proxy.bugzilla.getQueryInfo(self.user,self.password) - 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): - return self._proxy.bugzilla.getProdInfo(self.user, self.password) - def getproducts(self,force_refresh=False): - '''Return a dict of product names and product descriptions.''' - 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 _getcomponents(self,product): - return self._proxy.bugzilla.getProdCompInfo(product,self.user,self.password) - 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): - '''Returns a list of dicts giving details about the components in the - given product. Each item has the following keys: - component, description, initialowner, initialqacontact, initialcclist - ''' - return self._proxy.bugzilla.getProdCompDetails(product,self.user,self.password) - 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] - - 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 - - # Return raw dicts - def _getbug(self,id): - '''Return a dict of full bug info for the given bug id''' - return self._proxy.bugzilla.getBug(id, self.user, self.password) - def _getbugsimple(self,id): - '''Return a short dict of simple bug info for the given bug id''' - r = self._proxy.bugzilla.getBugSimple(id, self.user, self.password) - 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 - 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 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 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,self.user,self.password) - - # these return Bug objects - def getbug(self,id): - '''Return a Bug object with the full complement of bug data - already loaded.''' - 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. - ''' - 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) - - 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,self.password,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,self.password,comment,private,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,self.user,self.password) - - 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,self.password, - dupeid,fixedin,comment,isprivate,private_in_it,nomail) - - def _updatedeps(self,id,deplist): - #updateDepends($bug_id,$data,$username,$password,$nodependencyemail) - #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove') - raise NotImplementedError - - 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', 'remove', or 'makeexact'. - comment specifies an optional comment to add to the bug. - if mail is True, email will be generated for this change. - ''' - data = {'id':id, 'action':action, 'cc':','.join(cclist), - 'comment':comment, 'nomail':nomail} - return self._proxy.bugzilla.updateCC(data,self.user,self.password) - - 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,self.user,self.password) - - # 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,self.user,self.password) - - #---- 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.''' - # 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,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._proxy.bugzilla.addAttachment(id,kwargs,self.user,self.password) - return attachid - - def openattachment(self,attachid): - '''Get the contents of the attachment with the given attachment ID. - Returns a file-like object.''' - att_uri = self._url.replace('xmlrpc.cgi','attachment.cgi') - att_uri = att_uri + '?%i' % 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 - - def _createbug(self,**data): - '''Raw xmlrpc call for createBug() Doesn't bother guessing defaults - or checking argument validity. Use with care. - Returns [bug_id, mailresults]''' - return self._proxy.bugzilla.createBug(data,self.user,self.password) - - def createbug(self,check_args=False,**data): - '''Create a bug with the given info. Returns the bug ID. - 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" => "", - # REQUIRED Name of Bugzilla product. - # Ex: Red Hat Enterprise Linux - "component" => "", - # REQUIRED Name of component in Bugzilla product. - # Ex: anaconda - "version" => "", - # REQUIRED Version in the list for the Bugzilla product. - # Ex: 4.5 - # versions are listed in querydata['product'][]['versions'] - "rep_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" => "", - # REQUIRED One line summary describing the bug report. - "comment" => "", - # REQUIRED A detail descript about the bug report. - - "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" => "", - # OPTIONAL Will be determined by component owner otherwise. - "reporter" => "", - # OPTIONAL Will use current login if blank. - "qa_contact" => "", - # OPTIONAL Will be determined by component qa_contact otherwise. - "cc" => "", - # 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. - ''' - required = ('product','component','version','short_desc','comment', - 'rep_platform','bug_severity','op_sys','bug_file_loc') - # 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 required: - if i not in data or not data[i]: - 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, mail_results] = 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 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: - self.cookiejar = cookielib.CookieJar() - elif self.cookiejar: - # Let the cookiejar figure out what cookies are appropriate - self.cookiejar.add_cookie_header(cookie_request) - # Pull the cookie headers out of the request object... - cookielist=list() - for h,v in cookie_request.header_items(): - if h.startswith('Cookie'): - cookielist.append([h,v]) - # ...and put them over the connection - for h,v in cookielist: - connection.putheader(h,v) - - # 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/" % (self.scheme,host) - 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 - # fake a response object that we can fill with the headers above - class CookieResponse: - def __init__(self,headers): self.headers = headers - def info(self): return self.headers - cookie_response = CookieResponse(headers) - # Okay, extract the cookies from the headers - self.cookiejar.extract_cookies(cookie_response,cookie_request) - # And write back any changes - if hasattr(self.cookiejar,'save'): - self.cookiejar.save(self.cookiejar.filename) - - 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']: - self.__dict__.update(kwargs['dict']) - if 'bug_id' in kwargs: - 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'): - 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 - else: - desc = self.short_desc - return "#%-6s %-10s - %s - %s" % (self.bug_id,self.bug_status, - self.assigned_to,desc) - def __repr__(self): - return '' % (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) - # FIXME 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) - # FIXME 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) - # FIXME 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) - # FIXME 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) - # FIXME 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) -# TODO: add a sync() method that writes the changed data in the Bug object -# back to Bugzilla. Someday. diff --git a/bugzilla/__init__.py b/bugzilla/__init__.py new file mode 100644 index 0000000..0afe5ba --- /dev/null +++ b/bugzilla/__init__.py @@ -0,0 +1,45 @@ +# python-bugzilla - a Python interface to bugzilla using xmlrpclib. +# +# Copyright (C) 2007,2008 Red Hat Inc. +# Author: Will Woods +# +# 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 +from rhbugzilla import RHBugzilla +import xmlrpclib + +def getBugzillaClassForURL(url): + s = xmlrpclib.ServerProxy(url) + # RH Bugzilla method + prodinfo = {} + try: + prodinfo = s.bugzilla.getProdInfo() + return RHBugzilla + except xmlrpclib.Fault: + pass + + try: + r = s.Bugzilla.version() + version = r['version'] + if version.startswith('3.'): + return Bugzilla3 + except xmlrpclib.Fault: + pass + + return None + +class Bugzilla(object): + '''Magical Bugzilla class that figures out which Bugzilla implementation + to use and uses that.''' + def __init__(self,**kwargs): + if 'url' in kwargs: + c = getBugzillaClassForURL(kwargs['url']) + if c: + self.__class__ = c + c.__init__(self,**kwargs) + # FIXME raise an error or something here, jeez diff --git a/bugzilla/base.py b/bugzilla/base.py new file mode 100644 index 0000000..6fb8f89 --- /dev/null +++ b/bugzilla/base.py @@ -0,0 +1,843 @@ +# bugzilla.py - a Python interface to bugzilla.redhat.com, using xmlrpclib. +# +# Copyright (C) 2007,2008 Red Hat Inc. +# Author: Will Woods +# +# 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, cookielib +import os.path, base64, copy + +version = '0.5' +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) + + If you so desire, you can use cookie headers for authentication instead. + So you could do: + cf=glob(os.path.expanduser('~/.mozilla/firefox/default.*/cookies.txt')) + bz=Bugzilla(url=url,cookies=cf) + and, assuming you have previously logged info bugzilla with firefox, your + pre-existing auth cookie would be used, thus saving you the trouble of + stuffing your username and password in the bugzilla call. + On the other hand, this currently munges up the cookie so you'll have to + log back in when you next use bugzilla in firefox. So this is not + currently recommended. + + The methods which start with a single underscore are thin wrappers around + xmlrpc calls; those should be safe for multicall usage. + + 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.user_agent = user_agent + # Bugzilla object state info that users shouldn't mess with + self._cookiejar = None + self._proxy = None + self._opener = None + self._querydata = None + self._querydefaults = None + self._products = None + self._bugfields = None + self._components = dict() + self._components_details = dict() + if 'cookies' in kwargs: + self.readcookiefile(kwargs['cookies']) + if 'url' in kwargs: + self.connect(kwargs['url']) + if 'user' in kwargs: + self.user = kwargs['user'] + if 'password' in kwargs: + self.password = kwargs['password'] + + #---- Methods for establishing bugzilla connection and logging in + + def readcookiefile(self,cookiefile): + '''Read the given (Mozilla-style) cookie file and fill in the cookiejar, + allowing us to use the user's saved credentials to access bugzilla.''' + cj = cookielib.MozillaCookieJar() + cj.load(cookiefile) + self._cookiejar = cj + self._cookiejar.filename = cookiefile + + def connect(self,url): + '''Connect to the bugzilla instance with the given url.''' + # Set up the transport + if url.startswith('https'): + self._transport = SafeCookieTransport() + else: + self._transport = CookieTransport() + self._transport.user_agent = self.user_agent + self._transport.cookiejar = self._cookiejar or cookielib.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 + + # 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): + '''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 a dict of user info. + + Note that it is not required to login before calling other methods; + you may just set user and password and call whatever methods you like. + ''' + self.user = user + self.password = password + try: + r = self._proxy.bugzilla.login(self.user,self.password) + except xmlrpclib.Fault, f: + r = False + return r + + #---- 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 + + 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): + '''Return a dict of product names and product descriptions.''' + 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 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] + + 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): + '''IMPLEMENT ME: Return a dict of full bug info for the given bug id''' + raise NotImplementedError + def _getbugsimple(self,id): + '''IMPLEMENT ME: Return a short dict of simple bug info for the given + bug id''' + raise NotImplementedError + def _query(self,query): + '''IMPLEMENT ME: Query bugzilla and return a list of matching bugs.''' + raise NotImplementedError + + # 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 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 replace_getbug_errors_with_None(raw_results) + + # these return Bug objects + def getbug(self,id): + '''Return a Bug object with the full complement of bug data + already loaded.''' + 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. + ''' + 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,deplist): + '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. + updateDepends($bug_id,$data,$username,$password,$nodependencyemail) + #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')''' + 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', 'remove', or 'makeexact'. + comment specifies an optional comment to add to the bug. + if mail is True, email will be generated for this change. + ''' + 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 + + def _createbug(self,**data): + '''IMPLEMENT ME: Raw xmlrpc call for createBug() + Doesn't bother guessing defaults or checking argument validity. + Returns [bug_id, mailresults]''' + raise NotImplementedError + + def createbug(self,check_args=False,**data): + '''Create a bug with the given info. Returns the bug ID. + 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" => "", + # REQUIRED Name of Bugzilla product. + # Ex: Red Hat Enterprise Linux + "component" => "", + # REQUIRED Name of component in Bugzilla product. + # Ex: anaconda + "version" => "", + # REQUIRED Version in the list for the Bugzilla product. + # Ex: 4.5 + # versions are listed in querydata['product'][]['versions'] + "rep_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" => "", + # REQUIRED One line summary describing the bug report. + "comment" => "", + # REQUIRED A detail descript about the bug report. + + "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" => "", + # OPTIONAL Will be determined by component owner otherwise. + "reporter" => "", + # OPTIONAL Will use current login if blank. + "qa_contact" => "", + # OPTIONAL Will be determined by component qa_contact otherwise. + "cc" => "", + # 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. + ''' + required = ('product','component','version','short_desc','comment', + 'rep_platform','bug_severity','op_sys','bug_file_loc') + # 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 required: + if i not in data or not data[i]: + 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, mail_results] = 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 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: + self.cookiejar = cookielib.CookieJar() + elif self.cookiejar: + # Let the cookiejar figure out what cookies are appropriate + self.cookiejar.add_cookie_header(cookie_request) + # Pull the cookie headers out of the request object... + cookielist=list() + for h,v in cookie_request.header_items(): + if h.startswith('Cookie'): + cookielist.append([h,v]) + # ...and put them over the connection + for h,v in cookielist: + connection.putheader(h,v) + + # 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/" % (self.scheme,host) + 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 + # fake a response object that we can fill with the headers above + class CookieResponse: + def __init__(self,headers): self.headers = headers + def info(self): return self.headers + cookie_response = CookieResponse(headers) + # Okay, extract the cookies from the headers + self.cookiejar.extract_cookies(cookie_response,cookie_request) + # And write back any changes + if hasattr(self.cookiejar,'save'): + self.cookiejar.save(self.cookiejar.filename) + + 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']: + self.__dict__.update(kwargs['dict']) + if 'bug_id' in kwargs: + 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'): + 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 + else: + desc = self.short_desc + return "#%-6s %-10s - %s - %s" % (self.bug_id,self.bug_status, + self.assigned_to,desc) + def __repr__(self): + return '' % (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) + # FIXME 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) + # FIXME 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) + # FIXME 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) + # FIXME 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) + # FIXME 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) +# TODO: add a sync() method that writes the changed data in the Bug object +# back to Bugzilla. Someday. + +class Bugzilla(object): + '''Magical Bugzilla class that figures out which Bugzilla implementation + to use and uses that.''' + # FIXME STUB + pass diff --git a/bugzilla/bugzilla3.py b/bugzilla/bugzilla3.py new file mode 100644 index 0000000..3dda1a5 --- /dev/null +++ b/bugzilla/bugzilla3.py @@ -0,0 +1,189 @@ +# bugzilla3.py - a Python interface to Bugzilla 3.x using xmlrpclib. +# +# Copyright (C) 2008 Red Hat Inc. +# Author: Will Woods +# +# 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 + +version = '0.1' +user_agent = bugzilla.base.user_agent + ' Bugzilla3/%s' % version + +class Bugzilla3(bugzilla.base.BugzillaBase): + '''Concrete implementation of the Bugzilla protocol. This one uses the + methods provided by standard Bugzilla 3.x releases.''' + def __init__(self,**kwargs): + bugzilla.base.BugzillaBase.__init__(self,**kwargs) + self.user_agent = user_agent + + #---- Methods and properties with basic bugzilla info + + # Connect the backend methods to the XMLRPC methods + def _getbugfields(self): + #return self._proxy.bugzilla.getBugFields(self.user,self.password) + raise NotImplementedError + def _getqueryinfo(self): + #return self._proxy.bugzilla.getQueryInfo(self.user,self.password) + raise NotImplementedError + def _getproducts(self): + product_ids = self._proxy.Product.get_accessible_products() + return self._proxy.Product.get_products(product_ids) + def _getcomponents(self,product): + #return self._proxy.bugzilla.getProdCompInfo(product,self.user,self.password) + raise NotImplementedError + def _getcomponentsdetails(self,product): + #return self._proxy.bugzilla.getProdCompDetails(product,self.user,self.password) + raise NotImplementedError + + #---- 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.Bug.get(id) + def _getbugsimple(self,id): + '''Return a short dict of simple bug info for the given bug id''' + # Bugzilla3 doesn't have this + return self._getbug(id) + 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,self.user,self.password) + + #---- 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,self.password,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,self.password,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,self.password, + 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,self.user,self.password) + def _updatedeps(self,id,deplist): + '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. + updateDepends($bug_id,$data,$username,$password,$nodependencyemail) + #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')''' + raise NotImplementedError + 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', 'remove', or 'makeexact'. + comment specifies an optional comment to add to the bug. + if mail is True, email will be generated for this change. + ''' + data = {'id':id, 'action':action, 'cc':','.join(cclist), + 'comment':comment, 'nomail':nomail} + return self._proxy.bugzilla.updateCC(data,self.user,self.password) + 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,self.user,self.password) + # 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,self.user,self.password) + + #---- 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,self.user,self.password) + + #---- 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, mailresults]''' + return self._proxy.bugzilla.createBug(data,self.user,self.password) diff --git a/bugzilla/rhbugzilla.py b/bugzilla/rhbugzilla.py new file mode 100644 index 0000000..5bfaabb --- /dev/null +++ b/bugzilla/rhbugzilla.py @@ -0,0 +1,189 @@ +# rhbugzilla.py - a Python interface to Red Hat Bugzilla using xmlrpclib. +# +# Copyright (C) 2008 Red Hat Inc. +# Author: Will Woods +# +# 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 + +version = '0.1' +user_agent = bugzilla.base.user_agent + ' RHBugzilla/%s' % version + +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.''' + def __init__(self,**kwargs): + bugzilla.base.BugzillaBase.__init__(self,**kwargs) + self.user_agent = user_agent + + #---- Methods and properties with basic bugzilla info + + # Connect the backend methods to the XMLRPC methods + def _getbugfields(self): + return self._proxy.bugzilla.getBugFields(self.user,self.password) + def _getqueryinfo(self): + return self._proxy.bugzilla.getQueryInfo(self.user,self.password) + def _getproducts(self): + return self._proxy.bugzilla.getProdInfo(self.user, self.password) + def _getcomponents(self,product): + return self._proxy.bugzilla.getProdCompInfo(product,self.user,self.password) + def _getcomponentsdetails(self,product): + return self._proxy.bugzilla.getProdCompDetails(product,self.user,self.password) + + #---- 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, self.user, self.password) + def _getbugsimple(self,id): + '''Return a short dict of simple bug info for the given bug id''' + r = self._proxy.bugzilla.getBugSimple(id, self.user, self.password) + 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 + 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,self.user,self.password) + + #---- 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,self.password,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,self.password,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,self.password, + 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,self.user,self.password) + def _updatedeps(self,id,deplist): + '''IMPLEMENT ME: update the deps (blocked/dependson) for the given bug. + updateDepends($bug_id,$data,$username,$password,$nodependencyemail) + #data: 'blocked'=>id,'dependson'=>id,'action' => ('add','remove')''' + raise NotImplementedError + 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', 'remove', or 'makeexact'. + comment specifies an optional comment to add to the bug. + if mail is True, email will be generated for this change. + ''' + data = {'id':id, 'action':action, 'cc':','.join(cclist), + 'comment':comment, 'nomail':nomail} + return self._proxy.bugzilla.updateCC(data,self.user,self.password) + 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,self.user,self.password) + # 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,self.user,self.password) + + #---- 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,self.user,self.password) + + #---- 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, mailresults]''' + return self._proxy.bugzilla.createBug(data,self.user,self.password) diff --git a/setup.py b/setup.py index c73b0e4..34fd88f 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,13 @@ from distutils.core import setup from glob import glob -import bugzilla +import bugzilla.base setup(name='python-bugzilla', - version=str(bugzilla.version), + version=str(bugzilla.base.version), description='Bugzilla XMLRPC access module', author='Will Woods', author_email='wwoods@redhat.com', url='http://wwoods.fedorapeople.org/python-bugzilla/', - py_modules=['bugzilla'], - scripts=['bugzilla'], + packages = ['bugzilla'], + scripts=['bin/bugzilla'], ) -- cgit