#!/usr/bin/env python # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. # # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. # bugzilla-submit: a command-line script to post bugs to a Bugzilla instance # # Authors: Christian Reis # Eric S. Raymond # # This is version 0.5. # # For a usage hint run bugzilla-submit --help # # TODO: use RDF output to pick up valid options, as in # http://www.async.com.br/~kiko/mybugzilla/config.cgi?ctype=rdf import sys, string def error(m): sys.stderr.write("bugzilla-submit: %s\n" % m) sys.stderr.flush() sys.exit(1) version = string.split(string.split(sys.version)[0], ".")[:2] if map(int, version) < [2, 3]: error("you must upgrade to Python 2.3 or higher to use this script.") import urllib, re, os, netrc, email.Parser, optparse class ErrorURLopener(urllib.URLopener): """URLopener that handles HTTP 404s""" def http_error_404(self, url, fp, errcode, errmsg, headers, *extra): raise ValueError, errmsg # 'File Not Found' # Set up some aliases -- partly to hide the less friendly fieldnames # behind the names actually used for them in the stock web page presentation, # and partly to provide a place for mappings if the Bugzilla fieldnames # ever change. field_aliases = (('hardware', 'rep_platform'), ('os', 'op_sys'), ('summary', 'short_desc'), ('description', 'comment'), ('depends_on', 'dependson'), ('status', 'bug_status'), ('severity', 'bug_severity'), ('url', 'bug_file_loc'),) def header_to_field(hdr): hdr = hdr.lower().replace("-", "_") for (alias, name) in field_aliases: if hdr == alias: hdr = name break return hdr def field_to_header(hdr): hdr = "-".join(map(lambda x: x.capitalize(), hdr.split("_"))) for (alias, name) in field_aliases: if hdr == name: hdr = alias break return hdr def setup_parser(): # Take override values from the command line parser = optparse.OptionParser(usage="usage: %prog [options] bugzilla-url") parser.add_option('-b', '--status', dest='bug_status', help='Set the Status field.') parser.add_option('-u', '--url', dest='bug_file_loc', help='Set the URL field.') parser.add_option('-p', '--product', dest='product', help='Set the Product field.') parser.add_option('-v', '--version', dest='version', help='Set the Version field.') parser.add_option('-c', '--component', dest='component', help='Set the Component field.') parser.add_option('-s', '--summary', dest='short_desc', help='Set the Summary field.') parser.add_option('-H', '--hardware', dest='rep_platform', help='Set the Hardware field.') parser.add_option('-o', '--os', dest='op_sys', help='Set the Operating System field.') parser.add_option('-r', '--priority', dest='priority', help='Set the Priority field.') parser.add_option('-x', '--severity', dest='bug_severity', help='Set the Severity field.') parser.add_option('-d', '--description', dest='comment', help='Set the Description field.') parser.add_option('-a', '--assigned-to', dest='assigned_to', help='Set the Assigned-To field.') parser.add_option('-C', '--cc', dest='cc', help='Set the Cc field.') parser.add_option('-k', '--keywords', dest='keywords', help='Set the Keywords field.') parser.add_option('-D', '--depends-on', dest='dependson', help='Set the Depends-On field.') parser.add_option('-B', '--blocked', dest='blocked', help='Set the Blocked field.') parser.add_option('-n', '--no-stdin', dest='read', default=True, action='store_false', help='Suppress reading fields from stdin.') return parser # Fetch user's credential for access to this Bugzilla instance. def get_credentials(bugzilla): # Work around a quirk in the Python implementation. # The URL has to be quoted, otherwise the parser barfs on the colon. # But the parser doesn't strip the quotes. authenticate_on = '"' + bugzilla + '"' try: credentials = netrc.netrc() except netrc.NetrcParseError, e: error("ill-formed .netrc: %s:%s %s" % (e.filename, e.lineno, e.msg)) except IOError, e: error("missing .netrc file %s" % str(e).split()[-1]) ret = credentials.authenticators(authenticate_on) if not ret: # Okay, the literal string passed in failed. Just to make sure, # try adding/removing a slash after the address and looking # again. We don't know what format was used in .netrc, which is # why this rather hackish approach is necessary. if bugzilla[-1] == "/": authenticate_on = '"' + bugzilla[:-1] + '"' else: authenticate_on = '"' + bugzilla + '/"' ret = credentials.authenticators(authenticate_on) if not ret: # Apparently, an invalid machine URL will cause credentials == None error("no credentials for Bugzilla instance at %s" % bugzilla) return ret def process_options(options): data = {} # Initialize bug report fields from message on standard input if options.read: message_parser = email.Parser.Parser() message = message_parser.parse(sys.stdin) for (key, value) in message.items(): data.update({header_to_field(key) : value}) if not 'comment' in data: data['comment'] = message.get_payload() # Merge in options from the command line; they override what's on stdin. for (key, value) in options.__dict__.items(): if key != 'read' and value != None: data[key] = value return data def ensure_defaults(data): # Provide some defaults if the user did not supply them. if 'op_sys' not in data: if sys.platform.startswith('linux'): data['op_sys'] = 'Linux' if 'rep_platform' not in data: data['rep_platform'] = 'PC' if 'bug_status' not in data: data['bug_status'] = 'CONFIRMED' if 'bug_severity' not in data: data['bug_severity'] = 'normal' if 'bug_file_loc' not in data: data['bug_file_loc'] = 'http://' # Yes, Bugzilla needs this if 'priority' not in data: data['priority'] = 'Normal' def validate_fields(data): # Fields for validation required_fields = ( "bug_status", "bug_file_loc", "product", "version", "component", "short_desc", "rep_platform", "op_sys", "priority", "bug_severity", "comment", ) legal_fields = required_fields + ( "assigned_to", "cc", "keywords", "dependson", "blocked", ) my_fields = data.keys() for field in my_fields: if field not in legal_fields: error("invalid field: %s" % field_to_header(field)) for field in required_fields: if field not in my_fields: error("required field missing: %s" % field_to_header(field)) if not data['short_desc']: error("summary for bug submission must not be empty") if not data['comment']: error("comment for bug submission must not be empty") # # POST-specific functions # def submit_bug_POST(bugzilla, data): # Move the request over the wire postdata = urllib.urlencode(data) try: url = ErrorURLopener().open("%s/post_bug.cgi" % bugzilla, postdata) except ValueError: error("Bugzilla site at %s not found (HTTP returned 404)" % bugzilla) ret = url.read() check_result_POST(ret, data) def check_result_POST(ret, data): # XXX We can move pre-validation out of here as soon as we pick up # the valid options from config.cgi -- it will become a simple # assertion and ID-grabbing step. # # XXX: We use get() here which may return None, but since the user # might not have provided these options, we don't want to die on # them. version = data.get('version') product = data.get('product') component = data.get('component') priority = data.get('priority') severity = data.get('bug_severity') status = data.get('bug_status') assignee = data.get('assigned_to') platform = data.get('rep_platform') opsys = data.get('op_sys') keywords = data.get('keywords') deps = data.get('dependson', '') + " " + data.get('blocked', '') deps = deps.replace(" ", ", ") # XXX: we should really not be using plain find() here, as it can # match the bug content inadvertedly if ret.find("A legal Version was not") != -1: error("version %r does not exist for component %s:%s" % (version, product, component)) if ret.find("A legal Priority was not") != -1: error("priority %r does not exist in " "this Bugzilla instance" % priority) if ret.find("A legal Severity was not") != -1: error("severity %r does not exist in " "this Bugzilla instance" % severity) if ret.find("A legal Status was not") != -1: error("status %r is not a valid creation status in " "this Bugzilla instance" % status) if ret.find("A legal Platform was not") != -1: error("platform %r is not a valid platform in " "this Bugzilla instance" % platform) if ret.find("A legal OS/Version was not") != -1: error("%r is not a valid OS in " "this Bugzilla instance" % opsys) if ret.find("Invalid Username") != -1: error("invalid credentials submitted") if ret.find("Component Needed") != -1: error("the component %r does not exist in " "this Bugzilla instance" % component) if ret.find("Unknown Keyword") != -1: error("keyword(s) %r not registered in " "this Bugzilla instance" % keywords) if ret.find("The product name") != -1: error("product %r does not exist in this " "Bugzilla instance" % product) # XXX: this should be smarter if ret.find("does not exist") != -1: error("could not mark dependencies for bugs %s: one or " "more bugs didn't exist in this Bugzilla instance" % deps) if ret.find("Match Failed") != -1: # XXX: invalid CC hits on this error too error("the bug assignee %r isn't registered in " "this Bugzilla instance" % assignee) # If all is well, return bug number posted if ret.find("process_bug.cgi") == -1: error("could not post bug to %s: are you sure this " "is Bugzilla instance's top-level directory?" % bugzilla) m = re.search("Bug ([0-9]+) Submitted", ret) if not m: print ret error("Internal error: bug id not found; please report a bug") id = m.group(1) print "Bug %s posted." % id # # # if __name__ == "__main__": parser = setup_parser() # Parser will print help and exit here if we specified --help (options, args) = parser.parse_args() if len(args) != 1: parser.error("missing Bugzilla host URL") bugzilla = args[0] data = process_options(options) login, account, password = get_credentials(bugzilla) if "@" not in login: # no use even trying to submit error("login %r is invalid (it should be an email address)" % login) ensure_defaults(data) validate_fields(data) # Attach authentication information data.update({'Bugzilla_login' : login, 'Bugzilla_password' : password, 'GoAheadAndLogIn' : 1, 'form_name' : 'enter_bug'}) submit_bug_POST(bugzilla, data)