#!/usr/bin/python # # git-bz - git subcommand to integrate with bugzilla # # Copyright (C) 2008 Owen Taylor # # 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. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, If not, see # http://www.gnu.org/licenses/. # # Patches for git-bz # ================== # Send to Owen Taylor # # Installation # ============ # Copy or symlink somewhere in your path. # # Usage # ===== # # git bz add-url [-] [options] [] # # For each specified commit, rewrite the commit message to add the URL # of the given bug. You should only do this if you haven't already pushed # the commit publically. Running this directly is most useful as a fixup if # you forget to pass -u/--add-url to 'git bz attach' or 'git bz file'. # # Example: # # # Add a bug URL to the last commit # git bz attach 1234 HEAD^ # # git bz apply [options] # # For each patch attachment (except for obsolete patches) of the specified # bug, prompts whether to apply. If prompt is agreed to runs 'git am' on # the patch to apply it to the current branch. Aborts if 'git am' fails to # allow cleaning up conflicts. # # Examples: # # # Apply patches from the given bug # git bz apply bugzillla.gnome.org:1234 # # # Same, but add the bug URL to the commit messages # git bz apply -u bugzillla.gnome.org:1234 # # git bz attach [-] [options] [] # # For each specified commit, formats as a patch and attaches to the # specified bug, with the subject of the commit as the description and # the body of the commit as the comment. The patch formatting and and # specification of which commits are as for 'git format-patch' # # Prompts before actually doing anything to avoid mistakes. # # If -e/--edit is specified, then the user can edit the description and # comment for each patch, and (by uncommenting lines) obsolete old patches. # # Examples: # # # Attach the last commit # git bz attach bugzilla.gnome.org:1234 HEAD^ # # # Attach everything starting at an old commit # git bz attach bugzilla.gnome.org:1234 b50ea9bd^ # # # Attach a single old commit and rewrite the commit message # # to include the bug URL. (See 'git bz add-url') # git bz attach -u bugzilla.gnome.org:1234 b50ea9bd -1 # # git bz file [-] [options] [[]/] [ | ] # # Like 'attach', but files a new bug. Opens an editor for the user to # enter the summary and description for the bug. If only a single commit # is named, the summary defaults to the subject of the commit. The product and # component must be specified unless you have configured defaults. # # Examples: # # # File the last commit as a new bug on the default tracker # git bz file my-product/some-component HEAD^ # # # Same but rewrite the commit message to include the URL of the # # newly filed bug. (See 'git bz add-url') # git bz file -u my-product/some-component HEAD^ # # # File a bug with a series of patches starting from an old commit # # on a different bug tracker # git bz -b bugs.freedesktop.org file my-product/some-component b50ea9bd^ -1 # # Authentication # ============== # In order to use git-bz you need to already be logged into the bug tracker # in your web browser, and git-bz reads your browser cookie. Currently only # Firefox 3 is supported, and only on Linux. Patches to add more support and # to allow configuring username/password directly per bug tracker accepted. # # Bug references # ============== # Ways to refer to a bug: # : bug # on the default bug tracker # : : bug # on the given host # : : bug # on the given bug tracker alias (see below) # : An URL of the form http:///show_bug.cgi?id= # # Aliases # ======= # You can create short aliases for different bug trackers as follows # # git config --global bz-tracker.gnome.host bugzilla.gnome.org # # And you can set the default bug tracker with: # # git config --global bz.default-tracker gnome # # Per-Repository configuration # ============================ # Setting the default tracker, product and component in the local # config for a repository can be useful. Assuming that a global # 'gnome' alias has been set up as above: # # git config bz.default-tracker gnome # git config bz-tracker.gnome.default-product gnome-shell # git config bz-tracker.gnome.default-component general # # (The default-product and default-component values must always be # specified within the config for a particular tracker.) Note the # absence of the --global options. # # Per-Tracker Configuration # ========================= # git-bz needs some configuration specific to the bugzilla instance (tracker), # in particular it needs to know initial field values to use when submitting # bugs; legal values for some fields depend on the instance. # # You can also set whether to use http or https by setting the 'https' variabe # For https, *certificates are not checked* so you are completely vulnerable # to DNS spoofing and man-in-the-middle attacks. Blame httplib. # # Configuration comes from 4 sources, in descending order of priority # # 1) git configuration variables specified for the alias. # # git config --global bz-tracker.gnome.default-bug-severity trivial # # 2) git configuration variables specified for the host # # git config --global bz-tracker.bugzilla.gnome.org.default-bug-severity trivial # # 3) Host specific configuration in this file, see the CONFIG variable below # # 4) Default configuration in this file, see the DEFAULT_CONFIG variable below # # In general, settings that are necessary to make a popular bugzilla instance # work should be submitted back to me and go in the CONFIG variable. # DEFAULT_CONFIG = \ """ default-assigned-to = default-bug-file-loc = default-bug-severity = normal default-op-sys = All default-priority = P5 default-rep-platform = All default-version = unspecified """ CONFIG = {} CONFIG['bugs.freedesktop.org'] = \ """ https = true default-priority = medium """ CONFIG['bugzilla.gnome.org'] = \ """ default-priority = Normal """ CONFIG['bugzilla.mozilla.org'] = \ """ https = true default-priority = --- """ ################################################################################ from ConfigParser import RawConfigParser from httplib import HTTPConnection, HTTPSConnection from optparse import OptionParser import os from pysqlite2 import dbapi2 as sqlite import re from StringIO import StringIO from subprocess import Popen, CalledProcessError, PIPE import sys import tempfile import time import traceback import urllib from xml.etree.cElementTree import ElementTree # Globals # ======= # options dictionary from optparse global_options = None # Utility functions for git # ========================= # Run a git command # Non-keyword arguments are passed verbatim as command line arguments # Keyword arguments are turned into command line options # =True => -- # ='' => --= # Special keyword arguments: # _quiet: Discard all output even if an error occurs # _interactive: Don't capture stdout and stderr # _input=: Feed to stdinin of the command # def git_run(command, *args, **kwargs): to_run = ['git', command.replace("_", "-")] interactive = False quiet = False input = None interactive = False for (k,v) in kwargs.iteritems(): if k == '_quiet': quiet = True elif k == '_interactive': interactive = True elif k == '_input': input = v elif v is True: to_run.append("--" + k.replace("_", "-")) else: to_run.append("--" + k.replace("_", "-") + "=" + v) to_run.extend(args) process = Popen(to_run, stdout=(None if interactive else PIPE), stderr=(None if interactive else PIPE), stdin=(PIPE if (input != None) else None)) output, error = process.communicate(input) if process.returncode != 0: if not quiet and not interactive: print >>sys.stderr, error, print output, raise CalledProcessError(process.returncode, " ".join(to_run)) if interactive: return None else: return output.strip() # Wrapper to allow us to do git.(...) instead of git_run() class Git: def __getattr__(self, command): def f(*args, **kwargs): return git_run(command, *args, **kwargs) return f git = Git() class GitCommit: def __init__(self, id, subject): self.id = id self.subject = subject def rev_list_commits(*args, **kwargs): kwargs_copy = dict(kwargs) kwargs_copy['pretty'] = 'format:%s' output = git.rev_list(*args, **kwargs_copy) if output == "": lines = [] else: lines = output.split("\n") if (len(lines) % 2 != 0): raise RuntimeException("git rev-list didn't return an even number of lines") result = [] for i in xrange(0, len(lines), 2): m = re.match("commit\s+([A-Fa-f0-9]+)", lines[i]) if not m: raise RuntimeException("Can't parse commit it '%s'", lines[i]) commit_id = m.group(1) subject = lines[i + 1] result.append(GitCommit(commit_id, subject)) return result def get_commits(since_or_revision_range): if global_options.num: commits = rev_list_commits(since_or_revision_range, max_count=global_options.num) else: # git format-patch has special handling of specifying a single revision that is # different than git-rev-list. Match that. try: # See if the argument identifies a single revision rev = git.rev_parse(since_or_revision_range, verify=True, _quiet=True) revision_range = rev + ".." except CalledProcessError: # If not, assume the argument is a range revision_range = since_or_revision_range commits = rev_list_commits(revision_range) if len(commits) == 0: die("'%s' does not name any commits. Use HEAD^ to specify just the last commit" % since_or_revision_range) return commits def get_patch(commit): return git.format_patch(commit.id + "^.." + commit.id, stdout=True) def get_body(commit): return git.log(commit.id + "^.." + commit.id, pretty="format:%b") # Per-tracker configuration variables # =================================== def get_tracker(): if global_options.bugzilla != None: return global_options.bugzilla try: return git.config('bz.default-tracker', get=True) except CalledProcessError: return 'bugzilla.gnome.org' def resolve_host_alias(alias): try: return git.config('bz-tracker.' + alias + '.host', get=True) except CalledProcessError: return alias def split_local_config(config_text): result = {} for line in config_text.split("\n"): line = re.sub("#.*", "", line) line = line.strip() if line == "": continue m = re.match("([a-zA-Z0-9-]+)\s*=\s*(.*)", line) if not m: die("Bad config line '%s'" % line) param = m.group(1) value = m.group(2) result[param] = value return result def get_git_config(name): try: name = name.replace(".", r"\.") config_options = git.config(r'bz-tracker\.' + name + r'\..*', get_regexp=True) except CalledProcessError: return {} result = {} for line in config_options.split("\n"): line = line.strip() m = re.match("(\S+)\s+(.*)", line) key = m.group(1) value = m.group(2) m = re.match(r'bz-tracker\.' + name + r'\.(.*)', key) param = m.group(1) result[param] = value return result # We only ever should be the config for one tracker in the course of a single run cached_config = None cached_config_tracker = None def get_config(tracker): global cached_config global cached_config_tracker if cached_config == None: cached_config_tracker = tracker host = resolve_host_alias(tracker) cached_config = split_local_config(DEFAULT_CONFIG) if host in CONFIG: cached_config.update(split_local_config(CONFIG[host])) cached_config.update(get_git_config(host)) if tracker != host: cached_config.update(get_git_config(tracker)) assert cached_config_tracker == tracker return cached_config def tracker_uses_https(tracker): config = get_config(tracker) return 'https' in config and config['https'] == 'true' def get_default_fields(tracker): config = get_config(tracker) default_fields = {} for key, value in config.iteritems(): if key.startswith("default-"): param = key[8:].replace("-", "_") default_fields[param] = value return default_fields # Utility functions for bugzilla # ============================== def resolve_bug_reference(bug_reference): m = re.match("http(s?)://([^/]+)/show_bug.cgi\?id=([^&]+)", bug_reference) if m: return m.group(2), m.group(1) != "", m.group(3) colon = bug_reference.find(":") if colon > 0: tracker = bug_reference[0:colon] id = bug_reference[colon + 1:] else: tracker = get_tracker() id = bug_reference host = resolve_host_alias(tracker) https = tracker_uses_https(tracker) if not re.match(r"^.*\.[a-zA-Z]{2,}$", host): die("'%s' doesn't look like a valid bugzilla host or alias" % host) return host, https, id def get_bugzilla_cookies(host): profiles_dir = os.path.expanduser("~/.mozilla/firefox") profile_path = None cp = RawConfigParser() cp.read(os.path.join(profiles_dir, "profiles.ini")) for section in cp.sections(): if not cp.has_option(section, "Path"): continue if (not profile_path or (cp.has_option(section, "Default") and cp.get(section, "Default").strip() == "1")): profile_path = os.path.join(profiles_dir, cp.get(section, "Path").strip()) if not profile_path: die("Cannot find default Firefox profile") cookies_sqlite = os.path.join(profile_path, "cookies.sqlite") if not os.path.exists(cookies_sqlite): die("%s doesn't exist. Only Firefox 3 is supported currently") result = {} connection = sqlite.connect(cookies_sqlite) cursor = connection.cursor() cursor.execute("select name,value,path,expiry from moz_cookies where host = :host", { 'host': host }) now = time.time() for name,value,path,expiry in cursor.fetchall(): # Excessive caution: toss out values that need to be quoted in a cookie header if float(expiry) > now and not re.search(r'[()<>@,;:\\"/\[\]?={} \t]', value): result[name] = value connection.close() if not ('Bugzilla_login' in result and 'Bugzilla_logincookie' in result): die("You don't appear to be signed into %s; please log in with Firefox" % host) return result # Based on http://code.activestate.com/recipes/146306/ - Wade Leftwich def encode_multipart_formdata(fields, files): """ fields is a dictionary of { name : value } for regular form fields. if value is a list, one form field is added for each item in the list files is a dictionary of { name : ( filename, content_type, value) } for data to be uploaded as files Return (content_type, body) ready for httplib.HTTPContent instance """ BOUNDARY = '----------ThIs_Is_tHe_bouNdaRY_$' CRLF = '\r\n' L = [] for key in sorted(fields.keys()): value = fields[key] if isinstance(value, list): for v in value: L.append('--' + BOUNDARY) L.append('Content-Disposition: form-data; name="%s"' % key) L.append('') L.append(v) else: L.append('--' + BOUNDARY) L.append('Content-Disposition: form-data; name="%s"' % key) L.append('') L.append(value) for key in sorted(files.keys()): (filename, content_type, value) = files[key] L.append('--' + BOUNDARY) L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename)) L.append('Content-Type: %s' % content_type) L.append('') L.append(value) L.append('--' + BOUNDARY + '--') L.append('') body = CRLF.join(L) content_type = 'multipart/form-data; boundary=%s' % BOUNDARY return content_type, body # General Utility Functions # ========================= def make_filename(description): filename = re.sub(r"\s+", "-", description) filename = re.sub(r"[^A-Za-z0-9-]+", "", filename) filename = filename[0:50] return filename def edit_file(filename): editor = None if 'GIT_EDITOR' in os.environ: editor = os.environ['GIT_EDITOR'] if editor == None: try: editor = git.config('core.editor', get=True) except CalledProcessError: pass if editor == None and 'EDITOR' in os.environ: editor = os.environ['EDITOR'] if editor == None: editor = "vi" process = Popen(editor + " " + filename, shell=True) process.wait() if process.returncode != 0: die("Editor exited with non-zero return code") def edit_template(template): # Prompts the user to edit the text 'template' and returns list of # lines with comments stripped handle, filename = tempfile.mkstemp(".txt", "git-bz-") f = os.fdopen(handle, "w") f.write(template) f.close() edit_file(filename) f = open(filename, "r") lines = filter(lambda x: not x.startswith("#"), f.readlines()) f.close return lines def split_subject_body(lines): # Splits the first line (subject) from the subsequent lines (body) i = 0 subject = "" while i < len(lines): subject = lines[i].strip() if subject != "": break i += 1 return subject, "".join(lines[i + 1:]).strip() def prompt(message): print message, "[yn] ", line = sys.stdin.readline().strip() return line == 'y' or line == 'Y' def die(message): print >>sys.stderr, message sys.exit(1) # Classes for bug handling # ======================== class BugPatch(object): def __init__(self, attach_id, description, date): self.attach_id = attach_id self.description = description self.date = date class Bug(object): def __init__(self, host, https): self.host = host self.https = https self.id = None self.product = None self.component = None self.short_desc = None self.patches = [] self.cookies = get_bugzilla_cookies(host) if self.https: self.connection = HTTPSConnection(self.host, 443) else: self.connection = HTTPConnection(self.host, 80) def _send_request(self, method, url, data=None, headers={}): headers = dict(headers) cookie_string = ("Bugzilla_login=%s; Bugzilla_logincookie=%s" % (self.cookies['Bugzilla_login'], self.cookies['Bugzilla_logincookie'])) headers['Cookie'] = cookie_string headers['User-Agent'] = "git-bz" self.connection.request(method, url, data, headers) def _send_post(self, url, fields, files): content_type, body = encode_multipart_formdata(fields, files) self._send_request("POST", url, data=body, headers={ 'Content-Type': content_type }) def _load(self, id): url = "/show_bug.cgi?id=" + id + "&ctype=xml" self._send_request("GET", url) response = self.connection.getresponse() if response.status != 200: die ("Failed to retrieve bug information: %d" % response.status) etree = ElementTree() etree.parse(response) bug = etree.find("bug") error = bug.get("error") if error != None: die ("Failed to retrieve bug information: %s" % error) self.id = int(bug.find("bug_id").text) self.short_desc = bug.find("short_desc").text for attachment in bug.findall("attachment"): if attachment.get("ispatch") == "1" and not attachment.get("isobsolete") == "1" : attach_id = int(attachment.find("attachid").text) description = attachment.find("desc").text date = attachment.find("date").text self.patches.append(BugPatch(attach_id, description, date)) def _create(self, product, component, short_desc, comment, default_fields): fields = dict(default_fields) fields['product'] = product fields['component'] = component fields['short_desc'] = short_desc fields['comment'] = comment files = {} self._send_post("/post_bug.cgi", fields, files) response = self.connection.getresponse() response_data = response.read() if response.status != 200: print response_data die("Failed to create bug: %d" % response.status) m = re.search(r"\s*Bug\s+([0-9]+)", response_data) if not m: print response_data die("Filing bug failed") self.id = int(m.group(1)) print "Successfully created" print "Bug %d - %s" % (self.id, short_desc) print self.get_url() def create_patch(self, description, comment, filename, data, obsoletes=[]): fields = {} fields['bugid'] = str(self.id) fields['action'] = 'insert' fields['ispatch'] = '1' fields['description'] = description if comment: fields['comment'] = comment if obsoletes: # this will produce multiple parts in the encoded data with the # name 'obsolete' for each item in the list fields['obsolete'] = map(str, obsoletes) files = {} files['data'] = (filename, 'text/plain', data) self._send_post("/attachment.cgi", fields, files) response = self.connection.getresponse() response_data = response.read() if response.status != 200: die ("Failed to attach patch to bug: status=%d" % response.status) # We hope that this is constant across bugzilla versions for a # successful attachment m = re.search(r"<title>\s*Changes\s+Submitted", response_data) if not m: print response_data die ("Failed to attach patch to bug") print "Attached %s" % filename def download_patch(self, patch): self._send_request("GET", "/attachment.cgi?id=" + str(patch.attach_id)) response = self.connection.getresponse() if response.status != 200: die ("Failed to download attachment %s: %d" % (patch.attach_id, response.status)) return response.read() def get_url(self): return "%s://%s/show_bug.cgi?id=%d" % ("https" if self.https else "http", self.host, self.id) @staticmethod def load(bug_reference): (host, https, id) = resolve_bug_reference(bug_reference) bug = Bug(host, https) bug._load(id) return bug @staticmethod def create(tracker, product, component, short_desc, comment): host = resolve_host_alias(tracker) https = tracker_uses_https(tracker) default_fields = get_default_fields(tracker) bug = Bug(host, https) bug._create(product, component, short_desc, comment, default_fields) return bug # The Commands # ============= def check_add_url(commits): try: git.diff(exit_code=True) git.diff(exit_code=True, cached=True) except CalledProcessError: die("You must commit (or stash) all changes before using -u/--add-url") # We should check that all the commits are ancestors of the current # current revision, and maybe also check make sure that there are no # merge commits. def add_url(bug, commits): oldest_commit = commits[-1] newer_commits = rev_list_commits(commits[0].id + "..HEAD") head_id = newer_commits[0].id if newer_commits else oldest_commit.id try: print "Resetting to the parent revision" git.reset(oldest_commit.id + "^", hard=True) for commit in reversed(commits): body = get_body(commit) if str(bug.id) in body: print "Recommitting", commit.id[0:7], commit.subject, "(already has bug #)" git.cherry_pick(commit.id) # Find the new commit ID, though it doesn't matter much here commit.id = git.rev_list("HEAD^!") continue print "Adding URL ", commit.id[0:7], commit.subject git.cherry_pick(commit.id) input = commit.subject + "\n\n" + body + "\n\n" + bug.get_url() git.commit(file="-", amend=True, _input=input) # In this case, we need the new commit ID, so that when we later format the # patch, we format the patch with the added bug URL commit.id = git.rev_list("HEAD^!") for commit in reversed(newer_commits): print "Recommitting", commit.id[0:7], commit.subject git.cherry_pick(commit.id) commit.id = git.rev_list("HEAD^!") except: traceback.print_exc(None, sys.stderr) print >>sys.stderr print >>sys.stderr, "Something went wrong rewriting commmits to add URLs" print >>sys.stderr, "To restore to the original state: git reset --hard %s" % head_id[0:12] sys.exit(1) def do_add_url(bug_reference, since_or_revision_range): commits = get_commits(since_or_revision_range) check_add_url(commits) bug = Bug.load(bug_reference) print "Bug %d - %s" % (bug.id, bug.short_desc) print bug.get_url() print for commit in commits: print commit.id[0:7], commit.subject print if not prompt("Add bug URL to above commits?"): print "Aborting" sys.exit(0) print add_url(bug, commits) def do_apply(bug_reference): bug = Bug.load(bug_reference) print "Bug %d - %s" % (bug.id, bug.short_desc) print for patch in bug.patches: print patch.description if not prompt("Apply?"): continue print patch_contents = bug.download_patch(patch) handle, filename = tempfile.mkstemp(".patch", make_filename(patch.description) + "-") f = os.fdopen(handle, "w") f.write(patch_contents) f.close() try: process = git.am(filename, _interactive=True) except CalledProcessError: print "Patch left in %s" % filename break os.remove(filename) if global_options.add_url: # Slightly hacky, would be better to just commit right the first time commits = rev_list_commits("HEAD^!") add_url(bug, commits) def strip_bug_url(bug, commit_body): # Strip off the trailing bug URLs we add with -u; we do this before # using commit body in as a comment; doing it by stripping right before # posting means that we are robust against someone running add-url first # and attach second. pattern = "\s*" + re.escape(bug.get_url()) + "\s*$" return re.sub(pattern, "", commit_body) def edit_attachment_comment(bug, initial_description, initial_body): template = StringIO() template.write("# Attachment to Bug %d - %s\n\n" % (bug.id, bug.short_desc)) template.write(initial_description) template.write("\n\n") template.write(initial_body) template.write("\n\n") if len(bug.patches) > 0: for patch in bug.patches: template.write("#Obsoletes: %d - %s\n" % (patch.attach_id, patch.description)) template.write("\n") template.write("""# Please edit the description (first line) and comment (other lines). Lines # starting with '#' will be ignored. Delete everything to abort. """) if len(bug.patches) > 0: template.write("# To obsolete existing patches, uncomment the appropriate lines.\n") lines = edit_template(template.getvalue()) obsoletes= [] def filter_obsolete(line): m = re.match("^\s*Obsoletes\s*:\s*([\d]+)", line) if m: obsoletes.append(int(m.group(1))) return False else: return True lines = filter(filter_obsolete, lines) description, comment = split_subject_body(lines) if description == "": die("Empty description, aborting") return description, comment, obsoletes def attach_commits(bug, commits, include_comments=True, edit_comments=False): # We want to attach the patches in chronological order commits = list(commits) commits.reverse() for commit in commits: filename = make_filename(commit.subject) + ".patch" patch = get_patch(commit) if include_comments: body = strip_bug_url(bug, get_body(commit)) else: body = None if edit_comments: description, body, obsoletes = edit_attachment_comment(bug, commit.subject, body) else: description = commit.subject obsoletes = [] bug.create_patch(commit.subject, body, filename, patch, obsoletes=obsoletes) def do_attach(bug_reference, since_or_revision_range): commits = get_commits(since_or_revision_range) if global_options.add_url: check_add_url(commits) bug = Bug.load(bug_reference) # We always want to prompt if the user has specified multiple attachments. # For the common case of one attachment don't prompt if we are going # to give them a chance to edit and abort anyways. if len(commits) > 1 or not global_options.edit: print "Bug %d - %s" % (bug.id, bug.short_desc) print for commit in commits: print commit.id[0:7], commit.subject print if not prompt("Attach?"): print "Aborting" sys.exit(0) if global_options.add_url: add_url(bug, commits) attach_commits(bug, commits, edit_comments=global_options.edit) def do_file(*args): if len(args) == 1: product_component, since_or_revision_range = None, args[0] else: product_component, since_or_revision_range = args[0], args[1] config = get_config(get_tracker()) if product_component: m = re.match("(?:([^/]+)/)?([^/]+)", product_component) if not m: die("'%s' is not a valid [<product>/]<component>" % product_component) product = m.group(1) component = m.group(2) if not product: if not 'default-product' in config: die("'%s' does not specify a product and no default product is configured" % product_component) product = config['default-product'] else: if not 'default-product' in config: die("[<product>/]<component> not specified and no default product is configured") if not 'default-component' in config: die("[<product>/]<component> not specified and no default component is configured") product = config['default-product'] component = config['default-component'] commits = get_commits(since_or_revision_range) if global_options.add_url: check_add_url(commits) template = StringIO() if len(commits) == 1: template.write(commits[0].subject) template.write("\n") template.write(""" # Please enter the summary (first line) and description (other lines). Lines # starting with '#' will be ignored. Delete everything to abort. # # Product: %(product)s # Component: %(component)s # Patches to be attached: """ % { 'product': product, 'component': component }) for commit in commits: template.write("# " + commit.id[0:7] + " " + commit.subject + "\n") lines = edit_template(template.getvalue()) summary, description = split_subject_body(lines) if summary == "": die("Empty summary, aborting") # If we have only one patch and no other description for the bug was # specified, use the body of the commit as the the description for # the bug rather than the descriptionfor the attachment include_comments=True if len(commits) == 1: if description == "": description = get_body(commits[0]) include_comments = False bug = Bug.create(get_tracker(), product, component, summary, description) if global_options.add_url: add_url(bug, commits) attach_commits(bug, commits, include_comments=include_comments) ################################################################################ if len(sys.argv) > 1: command = sys.argv[1] else: command = '' sys.argv[1:2] = [] parser = OptionParser() parser.add_option("-b", "--bugzilla", metavar="HOST_OR_ALIAS", help="bug tracker to use") def add_num_option(): parser.add_option("", "--num", metavar="N", help="limit number of patches to attach (can abbreviate to -<N>)") for i, arg in enumerate(sys.argv): m = re.match("-([0-9]+)", arg) if m: sys.argv[i] = "--num=" + m.group(1) def add_add_url_option(): parser.add_option("-u", "--add-url", action="store_true", help="rewrite commits to add the bug URL") def add_edit_option(): parser.add_option("-e", "--edit", action="store_true", help="allow editing the bugzilla comment") if command == 'add-url': parser.set_usage("git bz add-url [-<N>] [options] <bug reference> [<since | <revision range>]"); add_num_option() min_args = max_args = 2 elif command == 'apply': parser.set_usage("git bz apply [options] <bug reference>"); add_add_url_option() min_args = max_args = 1 elif command == 'attach': parser.set_usage("git bz attach [-<N>] [options] <bug reference> [<since | <revision range>]"); add_add_url_option() add_num_option() add_edit_option() min_args = max_args = 2 elif command == 'file': parser.set_usage("git bz file [-<N>] [options] <product>/<component> [<since> | <revision range>]"); add_add_url_option() add_num_option() min_args = 1 max_args = 2 else: print >>sys.stderr, "Usage: git bz [add-url|apply|attach|file] [options]" sys.exit(1) global_options, args = parser.parse_args() if len(args) < min_args or len(args) > max_args: parser.print_usage() sys.exit(1) if command == 'add-url': do_add_url(*args) elif command == 'apply': do_apply(*args) elif command == 'attach': do_attach(*args) elif command == 'file': do_file(*args) sys.exit(0)