Add README
[git-bz.git] / git-bz
1 #!/usr/bin/python
2 #
3 # git-bz - git subcommand to integrate with bugzilla
4 #
5 # Copyright (C) 2008  Owen Taylor
6 #
7 # This program is free software; you can redistribute it and/or
8 # modify it under the terms of the GNU General Public License
9 # as published by the Free Software Foundation; either version 2
10 # of the License, or (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with this program; if not, If not, see
19 # http://www.gnu.org/licenses/.
20 #
21 # Patches for git-bz
22 # ==================
23 # Send to Owen Taylor <otaylor@fishsoup.net>
24 #
25 # Installation
26 # ============
27 # Copy or symlink somewhere in your path.
28 #
29 # Documentation
30 # =============
31 # See http://git.fishsoup.net/man/git-bz.html
32 # (generated from git-bz.txt in this directory.)
33 #
34 DEFAULT_CONFIG = \
35 """
36 default-assigned-to =
37 default-op-sys = All
38 default-platform = All
39 default-version = unspecified
40 """
41
42 CONFIG = {}
43
44 CONFIG['bugs.freedesktop.org'] = \
45 """
46 https = true
47 default-priority = medium
48 """
49
50 CONFIG['bugzilla.gnome.org'] = \
51 """
52 https = true
53 default-priority = Normal
54 """
55
56 CONFIG['bugzilla.mozilla.org'] = \
57 """
58 https = true
59 default-priority = ---
60 """
61
62 ################################################################################
63
64 import base64
65 import cPickle as pickle
66 from ConfigParser import RawConfigParser, NoOptionError
67 import httplib
68 import urllib
69 from optparse import OptionParser
70 import os
71 try:
72     from sqlite3 import dbapi2 as sqlite
73 except ImportError:
74     from pysqlite2 import dbapi2 as sqlite
75 import re
76 from StringIO import StringIO
77 from subprocess import Popen, CalledProcessError, PIPE
78 import shutil
79 import sys
80 import tempfile
81 import time
82 import traceback
83 import xmlrpclib
84 import urlparse
85 from xml.etree.cElementTree import ElementTree
86 import base64
87 import warnings
88
89 import smtplib
90 import random
91 import string
92
93
94 # Globals
95 # =======
96
97 # options dictionary from optparse
98 global_options = None
99
100 # Utility functions for git
101 # =========================
102
103 # Run a git command
104 #    Non-keyword arguments are passed verbatim as command line arguments
105 #    Keyword arguments are turned into command line options
106 #       <name>=True => --<name>
107 #       <name>='<str>' => --<name>=<str>
108 #    Special keyword arguments:
109 #       _quiet: Discard all output even if an error occurs
110 #       _interactive: Don't capture stdout and stderr
111 #       _input=<str>: Feed <str> to stdinin of the command
112 #       _return_error: Return tuple of captured (stdout,stderr)
113 #
114 def git_run(command, *args, **kwargs):
115     to_run = ['git', command.replace("_", "-")]
116
117     interactive = False
118     quiet = False
119     input = None
120     return_stderr = False
121     for (k,v) in kwargs.iteritems():
122         if k == '_quiet':
123             quiet = True
124         elif k == '_interactive':
125             interactive = True
126         elif k == '_return_stderr':
127             return_stderr = True
128         elif k == '_input':
129             input = v
130         elif v is True:
131             if len(k) == 1:
132                 to_run.append("-" + k)
133             else:
134                 to_run.append("--" + k.replace("_", "-"))
135         else:
136             to_run.append("--" + k.replace("_", "-") + "=" + v)
137
138     to_run.extend(args)
139
140     process = Popen(to_run,
141                     stdout=(None if interactive else PIPE),
142                     stderr=(None if interactive else PIPE),
143                     stdin=(PIPE if (input != None) else None))
144     output, error = process.communicate(input)
145     if process.returncode != 0:
146         if not quiet and not interactive:
147             # Using print here could result in Python adding a stray space
148             # before the next print
149             sys.stderr.write(error)
150             sys.stdout.write(output)
151         raise CalledProcessError(process.returncode, " ".join(to_run))
152
153     if interactive:
154         return None
155     elif return_stderr:
156         return output.strip(), error.strip()
157     else:
158         return output.strip()
159
160 # Wrapper to allow us to do git.<command>(...) instead of git_run()
161 class Git:
162     def __getattr__(self, command):
163         def f(*args, **kwargs):
164             return git_run(command, *args, **kwargs)
165         return f
166
167 git = Git()
168
169 class GitCommit:
170     def __init__(self, id, subject):
171         self.id = id
172         self.subject = subject
173
174 def rev_list_commits(*args, **kwargs):
175     kwargs_copy = dict(kwargs)
176     kwargs_copy['pretty'] = 'format:%s'
177     output = git.rev_list(*args, **kwargs_copy)
178     if output == "":
179         lines = []
180     else:
181         lines = output.split("\n")
182     if (len(lines) % 2 != 0):
183         raise RuntimeException("git rev-list didn't return an even number of lines")
184
185     result = []
186     for i in xrange(0, len(lines), 2):
187         m = re.match("commit\s+([A-Fa-f0-9]+)", lines[i])
188         if not m:
189             raise RuntimeException("Can't parse commit it '%s'", lines[i])
190         commit_id = m.group(1)
191         subject = lines[i + 1]
192         result.append(GitCommit(commit_id, subject))
193
194     return result
195
196 def get_commits(commit_or_revision_range):
197     # We take specifying a single revision to mean everything since that
198     # revision, while git-rev-list lists that revision and all ancestors
199     try:
200         # See if the argument identifies a single revision
201         rev = git.rev_parse(commit_or_revision_range, verify=True, _quiet=True)
202         commits = rev_list_commits(rev, max_count='1')
203     except CalledProcessError:
204         # If not, assume the argument is a range
205         commits = rev_list_commits(commit_or_revision_range)
206
207     if len(commits) == 0:
208         die("'%s' does not name any commits. Use HEAD to specify just the last commit" %
209             commit_or_revision_range)
210
211     return commits
212
213 def get_patch(commit):
214     # We could pass through -M as an option, but I think you basically always
215     # want it; showing renames as renames rather than removes/adds greatly
216     # improves readability.
217     return git.format_patch(commit.id + "^.." + commit.id, stdout=True, M=True)
218
219 def get_body(commit):
220     return git.log(commit.id + "^.." + commit.id, pretty="format:%b")
221
222 def commit_is_merge(commit):
223     contents = git.cat_file("commit", commit.id)
224     parent_count = 0
225     for line in contents.split("\n"):
226         if line == "":
227             break
228         if line.startswith("parent "):
229             parent_count += 1
230
231     return parent_count > 1
232
233 # Global configuration variables
234 # ==============================
235
236 def get_browser():
237     try:
238         return git.config('bz.browser', get=True)
239     except CalledProcessError:
240         return 'firefox3'
241
242 def get_tracker():
243     if global_options.bugzilla != None:
244         return global_options.bugzilla
245
246     try:
247         return git.config('bz.default-tracker', get=True)
248     except CalledProcessError:
249         return 'bugzilla.gnome.org'
250
251 def get_default_product():
252     try:
253         return git.config('bz.default-product', get=True)
254     except CalledProcessError:
255         return None
256
257 def get_default_component():
258     try:
259         return git.config('bz.default-component', get=True)
260     except CalledProcessError:
261         return None
262
263 def get_add_url():
264     try:
265         return git.config('bz.add-url', get=True) == 'true'
266     except CalledProcessError:
267         return True
268
269 def get_add_url_method():
270     try:
271         return git.config('bz.add-url-method', get=True)
272     except CalledProcessError:
273         return "body-append:%u"
274
275 # Per-tracker configuration variables
276 # ===================================
277
278 def resolve_host_alias(alias):
279     try:
280         return git.config('bz-tracker.' + alias + '.host', get=True)
281     except CalledProcessError:
282         return alias
283
284 def split_local_config(config_text):
285     result = {}
286
287     for line in config_text.split("\n"):
288         line = re.sub("#.*", "", line)
289         line = line.strip()
290         if line == "":
291             continue
292         m = re.match("([a-zA-Z0-9-]+)\s*=\s*(.*)", line)
293         if not m:
294             die("Bad config line '%s'" % line)
295
296         param = m.group(1)
297         value = m.group(2)
298
299         result[param] = value
300
301     return result
302
303 def get_git_config(name):
304     try:
305         name = name.replace(".", r"\.")
306         config_options = git.config(r'bz-tracker\.' + name + r'\..*', get_regexp=True)
307     except CalledProcessError:
308         return {}
309
310     result = {}
311     for line in config_options.split("\n"):
312         line = line.strip()
313         m = re.match("(\S+)\s+(.*)", line)
314         key = m.group(1)
315         value = m.group(2)
316
317         m = re.match(r'bz-tracker\.' + name + r'\.(.*)', key)
318         param = m.group(1)
319
320         result[param] = value
321
322     return result
323
324 # We only ever should be the config for one tracker in the course of a single run
325 cached_config = None
326 cached_config_tracker = None
327
328 def get_config(tracker):
329     global cached_config
330     global cached_config_tracker
331
332     if cached_config == None:
333         cached_config_tracker = tracker
334         host = resolve_host_alias(tracker)
335         cached_config = split_local_config(DEFAULT_CONFIG)
336         if host in CONFIG:
337             cached_config.update(split_local_config(CONFIG[host]))
338         cached_config.update(get_git_config(host))
339         if tracker != host:
340             cached_config.update(get_git_config(tracker))
341
342     assert cached_config_tracker == tracker
343
344     return cached_config
345
346 def tracker_uses_https(tracker):
347     config = get_config(tracker)
348     return 'https' in config and config['https'] == 'true'
349
350 def tracker_get_path(tracker):
351     config = get_config(tracker)
352     if 'path' in config:
353         return config['path']
354     return None
355
356 def tracker_get_auth_user(tracker):
357     config = get_config(tracker)
358     if 'auth-user' in config:
359         return config['auth-user']
360     return None
361
362 def tracker_get_auth_password(tracker):
363     config = get_config(tracker)
364     if 'auth-password' in config:
365         return config['auth-password']
366     return None
367
368 def tracker_get_bz_user(tracker):
369     config = get_config(tracker)
370     if 'bz-user' in config:
371         return config['bz-user']
372     return None
373
374 def tracker_get_bz_password(tracker):
375     config = get_config(tracker)
376     if 'bz-password' in config:
377         return config['bz-password']
378     return None
379
380 def get_default_fields(tracker):
381     config = get_config(tracker)
382
383     default_fields = {}
384
385     for key, value in config.iteritems():
386         if key.startswith("default-"):
387             param = key[8:].replace("-", "_")
388             default_fields[param] = value
389
390     return default_fields
391
392 # Utility functions for bugzilla
393 # ==============================
394
395 class BugParseError(Exception):
396     pass
397
398 # A BugHandle is the parsed form of a bug reference string; it
399 # uniquely identifies a bug on a server, though until we try
400 # to load it (and create a Bug) we don't know if it actually exists.
401 class BugHandle:
402     def __init__(self, host, path, https, id, auth_user=None, auth_password=None, bz_user=None, bz_password=None):
403         self.host = host
404         self.path = path
405         self.https = https
406         self.id = id
407         self.auth_user = auth_user
408         self.auth_password = auth_password
409         self.bz_user = bz_user
410         self.bz_password = bz_password
411
412         # ensure that the path to the bugzilla installation is an absolute path
413         # so that it will still work even if their config option specifies
414         # something like:
415         #   path = bugzilla
416         # instead of the proper form:
417         #   path = /bugzilla
418         if self.path and self.path[0] != '/':
419             self.path = '/' + self.path
420
421     def get_url(self):
422         return "%s://%s/show_bug.cgi?id=%s" % ("https" if self.https else "http",
423                                                self.host,
424                                                self.id)
425
426     def needs_auth(self):
427         return self.auth_user and self.auth_password
428
429     @staticmethod
430     def parse(bug_reference):
431         parseresult = urlparse.urlsplit (bug_reference)
432
433         if parseresult.scheme in ('http', 'https'):
434             # Catch http://www.gnome.org and the oddball http:relative/path and http:/path
435             if len(parseresult.path) == 0 or parseresult.path[0] != '/' or parseresult.hostname is None:
436                 raise BugParseError("Invalid bug reference '%s'" % bug_reference)
437
438             user = parseresult.username
439             password = parseresult.password
440             # if the url did not specify http auth credentials in the form
441             # https://user:password@host.com, check to see whether the config file
442             # specifies any auth credentials for this host
443             if not user:
444                 user = tracker_get_auth_user(parseresult.hostname)
445             if not password:
446                 password = tracker_get_auth_password(parseresult.hostname)
447
448             # strip off everything after the last '/', so '/bugzilla/show_bug.cgi'
449             # will simply become '/bugzilla'
450             base_path = parseresult.path[:parseresult.path.rfind('/')]
451             m = re.match("id=([^&]+)", parseresult.query)
452
453             if m:
454                 return BugHandle(host=parseresult.hostname,
455                                  path=base_path,
456                                  https=parseresult.scheme=="https",
457                                  id=m.group(1),
458                                  auth_user=user,
459                                  auth_password=password,
460                                  bz_user=tracker_get_bz_user(parseresult.hostname),
461                                  bz_password=tracker_get_bz_password(parseresult.hostname))
462
463         colon = bug_reference.find(":")
464         if colon > 0:
465             tracker = bug_reference[0:colon]
466             id = bug_reference[colon + 1:]
467         else:
468             tracker = get_tracker()
469             id = bug_reference
470
471         if not id.isdigit():
472             raise BugParseError("Invalid bug reference '%s'" % bug_reference)
473
474         host = resolve_host_alias(tracker)
475         https = tracker_uses_https(tracker)
476         path = tracker_get_path(tracker)
477         auth_user = tracker_get_auth_user(tracker)
478         auth_password = tracker_get_auth_password(tracker)
479         bz_user = tracker_get_bz_user(tracker)
480         bz_password = tracker_get_bz_password(tracker)
481
482         if not re.match(r"^.*\.[a-zA-Z]{2,}$", host):
483             raise BugParseError("'%s' doesn't look like a valid bugzilla host or alias" % host)
484
485         return BugHandle(host=host, path=path, https=https, id=id, auth_user=auth_user, auth_password=auth_password, bz_user=bz_user, bz_password=bz_password)
486
487     @staticmethod
488     def parse_or_die(str):
489         try:
490             return BugHandle.parse(str)
491         except BugParseError, e:
492             die(e.message)
493
494     def __hash__(self):
495         return hash((self.host, self.https, self.id))
496
497     def __eq__(self, other):
498         return ((self.host, self.https, self.id) ==
499                 (other.host, other.https, other.id))
500
501 class CookieError(Exception):
502     pass
503
504 def do_get_cookies_from_sqlite(host, cookies_sqlite, browser, query, chromium_time):
505     result = {}
506     # We use a timeout of 0 since we expect to hit the browser holding
507     # the lock often and we need to fall back to making a copy without a delay
508     connection = sqlite.connect(cookies_sqlite, timeout=0)
509
510     try:
511         cursor = connection.cursor()
512         cursor.execute(query, { 'host': host })
513
514         now = time.time()
515         for name,value,path,expiry in cursor.fetchall():
516             # Excessive caution: toss out values that need to be quoted in a cookie header
517             expiry = float(expiry)
518             if chromium_time:
519                 # Time stored in microseconds since epoch
520                 expiry /= 1000000.
521                 # Old chromium versions used to use the Unix epoch, but newer versions
522                 # use the Windows epoch of January 1, 1601. Convert the latter to Unix epoch
523                 if expiry > 11644473600:
524                     expiry -= 11644473600
525             if float(expiry) > now and not re.search(r'[()<>@,;:\\"/\[\]?={} \t]', value):
526                 result[name] = value
527
528         return result
529     finally:
530         connection.close()
531
532 # Firefox 3.5 keeps the cookies database permamently locked; as a workaround
533 # hack, we make a copy, read from that, then delete the copy. Of course,
534 # we may hit an inconsistent state of the database
535 def get_cookies_from_sqlite_with_copy(host, cookies_sqlite, browser, *args, **kwargs):
536     db_copy = cookies_sqlite + ".git-bz-temp"
537     shutil.copyfile(cookies_sqlite, db_copy)
538     try:
539         return do_get_cookies_from_sqlite(host, db_copy, browser, *args, **kwargs)
540     except sqlite.OperationalError, e:
541         raise CookieError("Cookie database was locked; temporary copy didn't work")
542     finally:
543         os.remove(db_copy)
544
545 def get_cookies_from_sqlite(host, cookies_sqlite, browser, query, chromium_time=False):
546     try:
547         result = do_get_cookies_from_sqlite(host, cookies_sqlite, browser, query,
548                                             chromium_time=chromium_time)
549     except sqlite.OperationalError, e:
550         if "database is locked" in str(e):
551             # Try making a temporary copy
552             result = get_cookies_from_sqlite_with_copy(host, cookies_sqlite, browser, query,
553                                                        chromium_time=chromium_time)
554         else:
555             raise
556
557     if not ('Bugzilla_login' in result and 'Bugzilla_logincookie' in result):
558         raise CookieError("You don't appear to be signed into %s; please log in with %s" % (host,
559                                                                                             browser))
560
561     return result
562
563 def get_cookies_from_sqlite_xulrunner(host, cookies_sqlite, name):
564     return get_cookies_from_sqlite(host, cookies_sqlite, name,
565                                    "select name,value,path,expiry from moz_cookies where host = :host")
566
567 def get_bugzilla_cookies_ff3(host):
568     profiles_dir = os.path.expanduser('~/.mozilla/firefox')
569     profile_path = None
570
571     cp = RawConfigParser()
572     cp.read(os.path.join(profiles_dir, "profiles.ini"))
573     for section in cp.sections():
574         if not cp.has_option(section, "Path"):
575             continue
576
577         if (not profile_path or
578             (cp.has_option(section, "Default") and cp.get(section, "Default").strip() == "1")):
579             profile_path = os.path.join(profiles_dir, cp.get(section, "Path").strip())
580
581     if not profile_path:
582         raise CookieError("Cannot find default Firefox profile")
583
584     cookies_sqlite = os.path.join(profile_path, "cookies.sqlite")
585     if not os.path.exists(cookies_sqlite):
586         raise CookieError("%s doesn't exist." % cookies_sqlite)
587
588     return get_cookies_from_sqlite_xulrunner(host, cookies_sqlite, "Firefox")
589
590 def get_bugzilla_cookies_epy(host):
591     # epiphany-webkit migrated the cookie db to a different location, but the
592     # format is the same
593     profile_dir = os.path.expanduser('~/.gnome2/epiphany')
594     cookies_sqlite = os.path.join(profile_dir, "cookies.sqlite")
595     if not os.path.exists(cookies_sqlite):
596         # try the old location
597         cookies_sqlite = os.path.join(profile_dir, "mozilla/epiphany/cookies.sqlite")
598
599     if not os.path.exists(cookies_sqlite):
600         raise CookieError("%s doesn't exist" % cookies_sqlite)
601
602     return get_cookies_from_sqlite_xulrunner(host, cookies_sqlite, "Epiphany")
603
604 # Shared for Chromium and Google Chrome
605 def get_bugzilla_cookies_chr(host, browser, config_dir):
606     config_dir = os.path.expanduser(config_dir)
607     cookies_sqlite = os.path.join(config_dir, "Cookies")
608     if not os.path.exists(cookies_sqlite):
609         raise CookieError("%s doesn't exist" % cookies_sqlite)
610     return get_cookies_from_sqlite(host, cookies_sqlite, browser,
611                                    "select name,value,path,expires_utc from cookies where host_key = :host",
612                                    chromium_time=True)
613
614 def get_bugzilla_cookies_chromium(host):
615     return get_bugzilla_cookies_chr(host,
616                                     "Chromium",
617                                     '~/.config/chromium/Default')
618
619 def get_bugzilla_cookies_google_chrome(host):
620     return get_bugzilla_cookies_chr(host,
621                                     "Google Chrome",
622                                     '~/.config/google-chrome/Default')
623
624 browsers = { 'firefox3'     : get_bugzilla_cookies_ff3,
625              'epiphany'     : get_bugzilla_cookies_epy,
626              'chromium'     : get_bugzilla_cookies_chromium,
627              'google-chrome': get_bugzilla_cookies_google_chrome }
628
629 def browser_list():
630     return ", ".join(sorted(browsers.keys()))
631
632 def get_bugzilla_cookies(host):
633     browser = get_browser()
634     if browser in browsers:
635         do_get_cookies = browsers[browser]
636     else:
637         die('Unsupported browser %s (we only support %s)' % (browser, browser_list()))
638
639     try:
640         return do_get_cookies(host)
641     except CookieError, e:
642         die("""Error getting login cookie from browser:
643    %s
644
645 Configured browser: %s (change with 'git config --global bz.browser <value>')
646 Possible browsers: %s""" %
647             (str(e), browser, browser_list()))
648
649 # Based on http://code.activestate.com/recipes/146306/ - Wade Leftwich
650 def encode_multipart_formdata(fields, files=None):
651     """
652     fields is a dictionary of { name : value } for regular form fields. if value is a list,
653       one form field is added for each item in the list
654     files is a dictionary of { name : ( filename, content_type, value) } for data to be uploaded as files
655     Return (content_type, body) ready for httplib.HTTPContent instance
656     """
657     BOUNDARY = '----------ThIs_Is_tHe_bouNdaRY_$'
658     CRLF = '\r\n'
659     L = []
660     for key in sorted(fields.keys()):
661         value = fields[key]
662         if isinstance(value, list):
663             for v in value:
664                 L.append('--' + BOUNDARY)
665                 L.append('Content-Disposition: form-data; name="%s"' % key)
666                 L.append('')
667                 L.append(v)
668         else:
669             L.append('--' + BOUNDARY)
670             L.append('Content-Disposition: form-data; name="%s"' % key)
671             L.append('')
672             L.append(value)
673     if files:
674         for key in sorted(files.keys()):
675             (filename, content_type, value) = files[key]
676             L.append('--' + BOUNDARY)
677             L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename))
678             L.append('Content-Type: %s' % content_type)
679             L.append('')
680             L.append(value)
681     L.append('--' + BOUNDARY + '--')
682     L.append('')
683     body = CRLF.join(L)
684     content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
685     return content_type, body
686
687 # Cache of constant-responses per bugzilla server
688 # ===============================================
689
690 CACHE_EXPIRY_TIME = 3600 * 24 # one day
691
692 class Cache(object):
693     def __init__(self):
694         self.cfp = None
695
696     def __ensure(self, host):
697         if self.cfp == None:
698             self.cfp = RawConfigParser()
699             self.cfp.read(os.path.expanduser("~/.git-bz-cache"))
700
701         if self.cfp.has_section(host):
702             if time.time() > self.cfp.getfloat(host, "expires"):
703                 self.cfp.remove_section(host)
704
705         if not self.cfp.has_section(host):
706             self.cfp.add_section(host)
707             self.cfp.set(host, "expires", time.time() + CACHE_EXPIRY_TIME)
708
709     def get(self, host, key):
710         self.__ensure(host)
711         try:
712             return pickle.loads(self.cfp.get(host, key))
713         except NoOptionError:
714             raise IndexError()
715
716     def set(self, host, key, value):
717         self.__ensure(host)
718         self.cfp.set(host, key, pickle.dumps(value))
719         f = open(os.path.expanduser("~/.git-bz-cache"), "w")
720         self.cfp.write(f)
721         f.close()
722
723 cache = Cache()
724
725 # General Utility Functions
726 # =========================
727
728 def make_filename(description):
729     filename = re.sub(r"\s+", "-", description)
730     filename = re.sub(r"[^A-Za-z0-9-]+", "", filename)
731     filename = filename[0:50]
732
733     return filename
734
735 def edit_file(filename):
736     editor = None
737     if 'GIT_EDITOR' in os.environ:
738         editor = os.environ['GIT_EDITOR']
739     if editor == None:
740         try:
741             editor = git.config('core.editor', get=True)
742         except CalledProcessError:
743             pass
744     if editor == None and 'EDITOR' in os.environ:
745         editor = os.environ['EDITOR']
746     if editor == None:
747         editor = "vi"
748
749     process = Popen(editor + " " + filename, shell=True)
750     process.wait()
751     if process.returncode != 0:
752         die("Editor exited with non-zero return code")
753
754 def edit_template(template):
755     # Prompts the user to edit the text 'template' and returns list of
756     # lines with comments stripped
757
758     handle, filename = tempfile.mkstemp(".txt", "git-bz-")
759     f = os.fdopen(handle, "w")
760     f.write(template)
761     f.close()
762
763     edit_file(filename)
764
765     f = open(filename, "r")
766     lines = filter(lambda x: not x.startswith("#"), f.readlines())
767     f.close
768
769     return lines
770
771 def split_subject_body(lines):
772     # Splits the first line (subject) from the subsequent lines (body)
773
774     i = 0
775     subject = ""
776     while i < len(lines):
777         subject = lines[i].strip()
778         if subject != "":
779             break
780         i += 1
781
782     return subject, "".join(lines[i + 1:]).strip()
783
784 def _shortest_unique_abbreviation(full, l):
785     for i in xrange(1, len(full) + 1):
786         abbrev = full[0:i]
787         if not any((x != full and x.startswith(abbrev) for x in l)):
788             return abbrev
789     # Duplicate items or one item is a prefix of another
790     raise ValueError("%s has no unique abbreviation in %s" % (full, l))
791
792 def _abbreviation_item_help(full, l):
793     abbrev = _shortest_unique_abbreviation(full, l)
794     return '[%s]%s' % (abbrev, full[len(abbrev):])
795
796 # Return '[a]pple, [pe]ar, [po]tato'
797 def abbreviation_help_string(l):
798     return ", ".join((_abbreviation_item_help(full, l) for full in l))
799
800 # Find the unique element in l that starts with abbrev
801 def expand_abbreviation(abbrev, l):
802     for full in l:
803         if full.startswith(abbrev) and len(abbrev) >= len(_shortest_unique_abbreviation(full, l)):
804             return full
805     raise ValueError("No unique abbreviation expansion")
806
807 def prompt(message):
808     while True:
809         # Using print here could result in Python adding a stray space
810         # before the next print
811         sys.stdout.write(message + " [yn] ")
812         line = sys.stdin.readline().strip()
813         if line == 'y' or line == 'Y':
814             return True
815         elif line == 'a' or line == 'A':
816             return 'a'
817         elif line == 'n' or line == 'N':
818             return False
819
820 def die(message):
821     print >>sys.stderr, message
822     sys.exit(1)
823
824 def http_auth_header(user, password):
825     return 'Basic ' + base64.encodestring("%s:%s" % (user, password)).strip()
826
827 # Classes for bug handling
828 # ========================
829
830 class BugPatch(object):
831     def __init__(self, attach_id):
832         self.attach_id = attach_id
833
834 class NoXmlRpcError(Exception):
835     pass
836
837 connections = {}
838
839 def get_connection(host, https):
840     identifier = (host, https)
841     if not identifier in connections:
842         if https:
843             connection = httplib.HTTPSConnection(host, 443)
844         else:
845             connection = httplib.HTTPConnection(host, 80)
846
847         connections[identifier] = connection
848
849     return connections[identifier]
850
851 class BugServer(object):
852     def __init__(self, host, path, https, auth_user=None, auth_password=None, bz_user=None, bz_password=None):
853         self.host = host
854         self.path = path
855         self.https = https
856         self.auth_user = auth_user
857         self.auth_password = auth_password
858         self.bz_password = bz_password
859         self.bz_user = bz_user
860
861         self.cookiestring = ''
862
863         self._xmlrpc_proxy = None
864
865     def get_cookie_string(self):
866         if self.cookiestring == '':
867             if self.bz_user and self.bz_password:
868                 connection = get_connection(self.host, self.https)
869                 connection.request("POST", self.path + "/index.cgi", urllib.urlencode({'Bugzilla_login':self.bz_user,'Bugzilla_password':self.bz_password}))
870                 res = connection.getresponse()
871                 self.cookiestring = res.getheader('set-cookie')
872                 connection.close()
873             else:
874                 self.cookies = get_bugzilla_cookies(host)
875                 self.cookiestring = ("Bugzilla_login=%s; Bugzilla_logincookie=%s" %
876                                      (self.cookies['Bugzilla_login'], self.cookies['Bugzilla_logincookie']))
877         return self.cookiestring
878
879     def send_request(self, method, url, data=None, headers={}):
880         headers = dict(headers)
881         headers['Cookie'] = self.get_cookie_string()
882         headers['User-Agent'] = "git-bz"
883         if self.auth_user and self.auth_password:
884             headers['Authorization'] = http_auth_header(self.auth_user, self.auth_password)
885         if self.path:
886             url = self.path + url
887
888         seen_urls = []
889         connection = get_connection(self.host, self.https)
890         while True:
891             connection.request(method, url, data, headers)
892             response = connection.getresponse()
893             seen_urls.append(url)
894
895             # Redirect status codes:
896             #
897             # 301 (Moved Permanently): Redo with the new URL,
898             #   save the new location.
899             # 303 (See Other): Redo with the method changed to GET/HEAD.
900             # 307 (Temporary Redirect): Redo with the new URL, don't
901             #   save the new location.
902             #
903             # [ For 301/307, you are supposed to ask the user if the
904             #   method isn't GET/HEAD, but we're automating anyways... ]
905             #
906             # 302 (Found): The confusing one, and the one that
907             # Bugzilla uses, both to redirect to http to https and to
908             # redirect attachment.cgi&action=view to a different base URL
909             # for security. Specified like 307, traditionally treated as 301.
910             #
911             # See http://en.wikipedia.org/wiki/HTTP_302
912
913             if response.status in (301, 302, 303, 307):
914                 new_url = response.getheader("location")
915                 if new_url is None:
916                     die("Redirect received without a location to redirect to")
917                 if new_url in seen_urls or len(seen_urls) >= 10:
918                     die("Circular redirect or too many redirects")
919
920                 old_split = urlparse.urlsplit(url)
921                 new_split = urlparse.urlsplit(new_url)
922
923                 new_https = new_split.scheme == 'https'
924
925                 if new_split.hostname != self.host or new_https != self.https:
926                     connection = get_connection(new_split.hostname, new_https != self.https)
927
928                     # This is a bit of a hack to avoid keeping on redirecting for every
929                     # request. If the server redirected show_bug.cgi we assume it's
930                     # really saying "hey, the bugzilla instance is really over here".
931                     #
932                     # We can't do this for old.split.path == new_split.path because of
933                     # attachment.cgi, though we alternatively could just exclude
934                     # attachment.cgi here.
935                     if (response.status in (301, 302) and
936                         method == 'GET' and
937                         old_split.path == '/show_bug.cgi' and new_split.path == '/show_bug.cgi'):
938
939                         self.host = new_split.hostname
940                         self.https = new_https
941
942                 # We can't treat 302 like 303 because of the use of 302 for http
943                 # to https, though the hack above will hopefully get us on https
944                 # before we try to POST.
945                 if response.status == 303:
946                     if method not in ('GET', 'HEAD'):
947                         method = 'GET'
948
949                 # Get the relative component of the new URL
950                 url = urlparse.urlunsplit((None, None, new_split.path, new_split.query, new_split.fragment))
951             else:
952                 return response
953
954     def send_post(self, url, fields, files=None):
955         content_type, body = encode_multipart_formdata(fields, files)
956         return self.send_request("POST", url, data=body, headers={ 'Content-Type': content_type })
957
958     def get_xmlrpc_proxy(self):
959         if self._xmlrpc_proxy is None:
960             uri = "%s://%s/xmlrpc.cgi" % ("https" if self.https else "http",
961                                           self.host)
962             if self.https:
963                 transport = SafeBugTransport(self)
964             else:
965                 transport = BugTransport(self)
966             self._xmlrpc_proxy = xmlrpclib.ServerProxy(uri, transport)
967
968         return self._xmlrpc_proxy
969
970     # Query the server for the legal values of the given field; returns an
971     # array, or None if the query failed
972     def _legal_values(self, field):
973         try:
974             response = self.get_xmlrpc_proxy().Bug.legal_values({ 'field': field })
975             cache.set(self.host, 'legal_' + field, response['values'])
976             return response['values']
977         except xmlrpclib.Fault, e:
978             if e.faultCode == -32000: # https://bugzilla.mozilla.org/show_bug.cgi?id=513511
979                 return None
980             raise
981         except xmlrpclib.ProtocolError, e:
982             if e.errcode == 500: # older bugzilla versions die this way
983                 return None
984             elif e.errcode == 404: # really old bugzilla, no XML-RPC
985                 return None
986             raise
987
988     def legal_values(self, field):
989         try:
990             return cache.get(self.host, 'legal_' + field)
991         except IndexError:
992             values = self._legal_values(field)
993             cache.set(self.host, 'legal_' + field, values)
994             return values
995
996 # mixin for xmlrpclib.Transport classes to add cookies
997 class CookieTransportMixin(object):
998     def send_request(self, connection, *args):
999         xmlrpclib.Transport.send_request(self, connection, *args)
1000         connection.putheader("Cookie", self.server.get_cookie_string())
1001         connection.putheader("Authorization", http_auth_header(self.server.auth_user, self.server.auth_password))
1002
1003 class BugTransport(CookieTransportMixin, xmlrpclib.Transport):
1004     def __init__(self, server):
1005         xmlrpclib.Transport.__init__(self)
1006         self.server = server
1007
1008 class SafeBugTransport(CookieTransportMixin, xmlrpclib.SafeTransport):
1009     def __init__(self, server):
1010         xmlrpclib.SafeTransport.__init__(self)
1011         self.server = server
1012
1013 servers = {}
1014
1015 # Note that if we detect that we are redirecting, we may rewrite the
1016 # host/https of the server to avoid doing too many redirections, and
1017 # so the host,https we connect to may be different than what we use
1018 # to look up the server.
1019 def get_bug_server(host, path, https, auth_user, auth_password, bz_user, bz_password):
1020     identifier = (host, path, https)
1021     if not identifier in servers:
1022         servers[identifier] = BugServer(host, path, https, auth_user, auth_password, bz_user, bz_password)
1023
1024     return servers[identifier]
1025
1026
1027 # Unfortunately, Bugzilla doesn't set a useful status code for
1028 # form posts.  Because it's very confusing to claim we succeeded
1029 # but not, we look for text in the response indicating success,
1030 # and not text indicating failure.
1031 #
1032 # We generally look for specific <title> tags - these have been
1033 # quite stable across versions, though translations will throw
1034 # us off.
1035 #
1036 # *args are regular expressions to search for in response_data
1037 # that indicate success. Returns the matched regular expression
1038 # on success, None otherwise
1039 def check_for_success(response, response_data, *args):
1040
1041     if response.status != 200:
1042         return False
1043
1044     for pattern in args:
1045         m = re.search(pattern, response_data)
1046         if m:
1047             return m
1048
1049     return None
1050
1051 class Bug(object):
1052     def __init__(self, server):
1053         self.server = server
1054         self.id = None
1055         self.product = None
1056         self.component = None
1057         self.short_desc = None
1058         self.patches = []
1059
1060     def _load(self, id, attachmentdata=False):
1061         url = "/show_bug.cgi?id=" + id + "&ctype=xml"
1062         if not attachmentdata:
1063             url += "&excludefield=attachmentdata"
1064
1065         response = self.server.send_request("GET", url)
1066         if response.status != 200:
1067             die ("Failed to retrieve bug information: %d" % response.status)
1068
1069         etree = ElementTree()
1070         etree.parse(response)
1071
1072         bug = etree.find("bug")
1073         error = bug.get("error")
1074         if error != None:
1075             die ("Failed to retrieve bug information: %s" % error)
1076
1077         self.id = int(bug.find("bug_id").text)
1078         self.short_desc = bug.find("short_desc").text
1079         self.bug_status = bug.find("bug_status").text
1080         if self.bug_status == "RESOLVED":
1081             self.resolution = bug.find("resolution").text
1082         token = bug.find("token")
1083         self.token = None if token is None else token.text
1084
1085         for attachment in bug.findall("attachment"):
1086             if attachment.get("ispatch") == "1" and not attachment.get("isobsolete") == "1" :
1087                 attach_id = int(attachment.find("attachid").text)
1088                 patch = BugPatch(attach_id)
1089                 # We have to save fields we might not otherwise care about
1090                 # (like isprivate) so that we can pass them back when updating
1091                 # the attachment
1092                 patch.description = attachment.find("desc").text
1093                 patch.date = attachment.find("date").text
1094                 status = attachment.find("status")
1095                 patch.status = None if status is None else status.text
1096                 patch.filename = attachment.find("filename").text
1097                 patch.isprivate = attachment.get("isprivate") == "1"
1098                 token = attachment.find("token")
1099                 patch.token = None if token is None else token.text
1100
1101                 if attachmentdata:
1102                     data = attachment.find("data").text
1103                     patch.data = base64.b64decode(data)
1104                 else:
1105                     patch.data = None
1106
1107                 self.patches.append(patch)
1108
1109     def _create_via_xmlrpc(self, product, component, short_desc, comment, default_fields):
1110         params = dict()
1111         params['product'] = product
1112         params['component'] = component
1113         params['summary'] = short_desc
1114         params['description'] = comment
1115         for (field, value) in default_fields.iteritems():
1116             params[field] = value
1117
1118         try:
1119             response = self.server.get_xmlrpc_proxy().Bug.create(params)
1120             self.id = response['id']
1121         except xmlrpclib.Fault, e:
1122             die(e.faultString)
1123         except xmlrpclib.ProtocolError, e:
1124             if e.errcode == 404:
1125                 raise NoXmlRpcError(e.errmsg)
1126             else:
1127                 print >>sys.stderr, "Problem filing bug via XML-RPC: %s (%d)\n" % (e.errmsg, e.errcode)
1128                 print >>sys.stderr, "falling back to form post\n"
1129                 raise NoXmlRpcError("Communication error")
1130
1131     def _create_with_form(self, product, component, short_desc, comment, default_fields):
1132         fields = dict()
1133         fields['product'] = product
1134         fields['component'] = component
1135         fields['short_desc'] = short_desc
1136         fields['comment'] = comment
1137
1138         # post_bug.cgi wants some names that are less congenial than the names
1139         # expected in XML-RPC.
1140         for (field, value) in default_fields.iteritems():
1141             if field == 'severity':
1142                 field = 'bug_severity'
1143             elif field == 'platform':
1144                 field = 'rep_platform'
1145             fields[field] = value
1146
1147         # Priority values vary wildly between different servers because the stock
1148         # Bugzilla uses the awkward P1/../P5. It will be defaulted on the XML-RPC
1149         # code path, but we need to take a wild guess here.
1150         if not 'priority' in fields:
1151             fields['priority'] = 'P5'
1152         # Legal severity values are much more standardized, but not specifying it
1153         # in the XML-RPC code path allows the server default to win. We need to
1154         # specify something here.
1155         if not 'severity' in fields:
1156             fields['bug_severity'] = 'normal'
1157         # Required, but a configured default doesn't make any sense
1158         if not 'bug_file_loc' in fields:
1159             fields['bug_file_loc'] = ''
1160
1161         response = self.server.send_post("/post_bug.cgi", fields)
1162         response_data = response.read()
1163         m = check_for_success(response, response_data,
1164                               r"<title>\s*Bug\s+([0-9]+)")
1165         if not m:
1166             print response_data
1167             die("Failed to create bug, status=%d" % response.status)
1168
1169         self.id = int(m.group(1))
1170
1171     def _create(self, product, component, short_desc, comment, default_fields):
1172         try:
1173             self._create_via_xmlrpc(product, component, short_desc, comment, default_fields)
1174         except NoXmlRpcError:
1175             self._create_with_form(product, component, short_desc, comment, default_fields)
1176
1177         print "Successfully created"
1178         print "Bug %d - %s" % (self.id, short_desc)
1179         print self.get_url()
1180
1181     def create_patch(self, description, comment, filename, data, obsoletes=[], status='none'):
1182         fields = {}
1183         fields['bugid'] = str(self.id)
1184         fields['action'] = 'insert'
1185         fields['ispatch'] = '1'
1186         fields['attachments.status'] = status
1187         fields['description'] = description
1188         if comment:
1189             fields['comment'] = comment
1190         if obsoletes:
1191             # this will produce multiple parts in the encoded data with the
1192             # name 'obsolete' for each item in the list
1193             fields['obsolete'] = map(str, obsoletes)
1194
1195         files = { 'data': (filename, 'text/plain', data) }
1196         response = self.server.send_post("/attachment.cgi", fields, files)
1197         response_data = response.read()
1198         if not check_for_success(response, response_data,
1199                                  # Older bugzilla's used this for successful attachments
1200                                  r"<title>\s*Changes\s+Submitted",
1201                                  # Newer bugzilla's, use, instead:
1202                                  r"<title>\s*Attachment\s+\d+\s+added"):
1203             print response_data
1204             die ("Failed to attach patch to bug %d, status=%d" % (self.id, response.status))
1205
1206         print "Attached %s" % filename
1207
1208         if global_options.mail:
1209             N=6
1210             tempfile = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(N))
1211             f = open('/tmp/'+tempfile, 'w')
1212             f.write(data)
1213             f.close()
1214             mlist = "koha-patches@lists.koha-community.org"
1215             str1 = "git send-email  --quiet  --confirm never  --to '" + mlist +"' /tmp/"+tempfile
1216
1217             import os
1218             retvalue = os.system(str1)
1219             print retvalue
1220
1221     # Update specified fields of a bug; keyword arguments are interpreted
1222     # as field_name=value
1223     def update(self, **changes):
1224         changes['id'] = str(self.id)
1225         if self.token:
1226             changes['token'] = self.token
1227         # Since we don't send delta_ts we'll never get a mid-air collision
1228         # This is probably a good thing
1229
1230         response = self.server.send_post("/process_bug.cgi", changes)
1231         response_data = response.read()
1232         if not check_for_success(response, response_data,
1233                                  r"<title>\s*Bug[\S\s]*processed\s*</title>"):
1234
1235             # Mid-air collisions would be indicated by
1236             # "<title>Mid-air collision!</title>"
1237
1238             print response_data
1239             die ("Failed to update bug %d, status=%d" % (self.id, response.status))
1240
1241     # Update specified fields of an attachment; keyword arguments are
1242     # interpreted as field_name=value
1243     def update_patch(self, patch, **changes):
1244         # Unlike /process_bug.cgi, the attachment editing interface doesn't
1245         # support defaulting missing fields to their existing values, so we
1246         # have to pass everything back.
1247         fields = {
1248             'action': 'update',
1249             'id': str(patch.attach_id),
1250             'description': patch.description,
1251             'filename': patch.filename,
1252             'ispatch': "1",
1253             'isobsolete': "0",
1254             'isprivate': "1" if patch.isprivate else "0",
1255         };
1256
1257         if patch.token:
1258             fields['token'] = patch.token
1259         if patch.status is not None:
1260             fields['attachments.status'] = patch.status
1261
1262         for (field, value) in changes.iteritems():
1263             if field == 'status': # encapsulate oddball form field name
1264                 field = 'attachments.status'
1265             fields[field] = value
1266
1267         response = self.server.send_post("/attachment.cgi", fields)
1268         response_data = response.read()
1269         if not check_for_success(response, response_data,
1270                                  r"<title>\s*Changes\s+Submitted"):
1271             print response_data
1272             die ("Failed to update attachment %d to bug %d, status=%d" % (patch.attach_id,
1273                                                                           self.id,
1274                                                                           response.status))
1275
1276     def get_url(self):
1277         return "%s://%s/show_bug.cgi?id=%d" % ("https" if self.server.https else "http",
1278                                                self.server.host,
1279                                                self.id)
1280
1281     @staticmethod
1282     def load(bug_reference, attachmentdata=False):
1283         server = get_bug_server(bug_reference.host, bug_reference.path, bug_reference.https, bug_reference.auth_user, bug_reference.auth_password, bug_reference.bz_user, bug_reference.bz_password)
1284         bug = Bug(server)
1285         bug._load(bug_reference.id, attachmentdata)
1286
1287         return bug
1288
1289     @staticmethod
1290     def create(tracker, product, component, short_desc, comment):
1291         host = resolve_host_alias(tracker)
1292         https = tracker_uses_https(tracker)
1293         path = tracker_get_path(tracker)
1294         auth_user = tracker_get_auth_user(tracker)
1295         auth_password = tracker_get_auth_password(tracker)
1296         bz_user = tracker_get_bz_user(tracker)
1297         bz_password = tracker_get_bz_password(tracker)
1298         default_fields = get_default_fields(tracker)
1299
1300         server = get_bug_server(host, path, https, auth_user, auth_password, bz_user, bz_password)
1301         bug = Bug(server)
1302         bug._create(product, component, short_desc, comment, default_fields)
1303
1304         return bug
1305
1306 # The Commands
1307 # =============
1308
1309 def commit_needs_url(commit, bug_id):
1310         pat = re.compile(r"(?<!\d)%d(?!\d)" % bug_id)
1311         return (pat.search(commit.subject) is None and
1312                 pat.search(get_body(commit)) is None)
1313
1314 def check_add_url(commits, bug_id=None, is_add_url=False):
1315     if bug_id != None:
1316         # We only need to check the commits that we'll add the URL to
1317         commits = [commit for commit in commits if commit_needs_url(commit, bug_id)]
1318
1319     if len(commits) == 0: # Nothing to do
1320         return
1321
1322     try:
1323         git.diff(exit_code=True, ignore_submodules=True, _quiet=True)
1324         git.diff(exit_code=True, ignore_submodules=True, cached=True,  _quiet=True)
1325     except CalledProcessError:
1326         die("Cannot add bug reference to commit message(s); You must commit (or stash) all changes first")
1327
1328     for commit in commits:
1329         # check that the commit is an ancestor of the current revision
1330         base = git.merge_base("HEAD", commit.id)
1331         if base != commit.id:
1332             die("%s %s\nNot an ancestor of HEAD, can't add bug URL to it" % (commit.id[0:7], commit.subject))
1333
1334         # see if the commit is present in any remote branches
1335         remote_branches = git.branch(contains=commit.id, r=True)
1336         if remote_branches != "":
1337             print commit.id[0:7], commit.subject
1338             print "Commit is already in remote branch(es):", " ".join(remote_branches.split())
1339             if not prompt("Rewrite the commit add the bug URL anyways?"):
1340                 if is_add_url:
1341                     print "Aborting."
1342                 else:
1343                     print "Aborting. You can use -n/--no-add-url to turn off adding the URL"
1344                 sys.exit(0)
1345
1346     # Check for merge commits
1347     oldest_commit = commits[-1]
1348     all_commits = rev_list_commits(commits[-1].id + "^..HEAD")
1349     for commit in all_commits:
1350         if commit_is_merge(commit):
1351             print "Found merge commit:"
1352             print commit.id[0:7], commit.subject
1353             print "Can't rewrite this commit or an ancestor commit to add bug URL"
1354             sys.exit(1)
1355
1356 def bad_url_method(add_url_method):
1357     die("""add-url-method '%s' is invalid
1358 Should be [subject-prepend|subject-append|body-prepend|body-append]:<format>""" %
1359         add_url_method)
1360
1361 def add_url_to_subject_body(subject, body, bug):
1362     add_url_method = get_add_url_method()
1363     if not ':' in add_url_method:
1364         bad_url_method(add_url_method)
1365
1366     method, format = add_url_method.split(':', 1)
1367
1368     def sub_percent(m):
1369         if m.group(1) == 'u':
1370             return bug.get_url()
1371         elif m.group(1) == 'd':
1372             return str(bug.id)
1373         elif m.group(1) == 'n':
1374             return "\n"
1375         elif m.group(1) == '%':
1376             return "%"
1377         else:
1378             die("Bad add-url-method escape %%%s" % m.group(1))
1379
1380     formatted = re.sub("%(.)", sub_percent, format)
1381
1382     if method == 'subject-prepend':
1383         subject = formatted + " " + subject
1384     elif method == 'subject-append':
1385         subject = subject + " " + formatted
1386     elif method == 'body-prepend':
1387         body = formatted + "\n\n" + body
1388     elif method == 'body-append':
1389         body = body + "\n\n" + formatted
1390     else:
1391         bad_url_method(add_url_method)
1392
1393     return subject, body
1394
1395 def validate_add_url_method(bug):
1396     # Dry run
1397     add_url_to_subject_body("", "", bug)
1398
1399 def add_url_to_head_commit(commit, bug):
1400     subject = commit.subject
1401     body = get_body(commit)
1402
1403     subject, body = add_url_to_subject_body(subject, body, bug)
1404
1405     input = subject + "\n\n" + body
1406     git.commit(file="-", amend=True, _input=input)
1407
1408 def add_url(bug, commits):
1409     commit_map = {}
1410     oldest_commit = None
1411     for commit in commits:
1412         commit_map[commit.id] = commit
1413         if commit_needs_url(commit, bug.id):
1414             oldest_commit = commit
1415
1416     if not oldest_commit:
1417         return
1418
1419     # Check that the add-url method is valid before starting the rebase
1420     validate_add_url_method(bug)
1421
1422     all_commits = rev_list_commits(oldest_commit.id + "^..HEAD")
1423     orig_head = all_commits[0].id
1424
1425     try:
1426         branch_name = git.symbolic_ref("HEAD", q=True)
1427     except CalledProcessError:
1428         branch_name = None
1429     try:
1430         # Detach HEAD from the branch; this gives a cleaner reflog for the branch
1431         print "Moving to starting point"
1432         git.checkout(oldest_commit.id + "^", q=True)
1433
1434         for commit in reversed(all_commits):
1435             # Map back to the original commit object so we can update it
1436             if commit.id in commit_map:
1437                 commit = commit_map[commit.id]
1438
1439             if commit.id in commit_map and commit_needs_url(commit, bug.id):
1440                 print "Adding bug reference  ", commit.id[0:7], commit.subject
1441                 git.cherry_pick(commit.id)
1442                 add_url_to_head_commit(commit, bug)
1443             else:
1444                 if commit.id in commit_map:
1445                     print "Recommitting", commit.id[0:7], commit.subject, "(already has bug #)"
1446                 else:
1447                     print "Recommitting", commit.id[0:7], commit.subject
1448                 git.cherry_pick(commit.id)
1449
1450             # Get the commit ID; we update the commit with the new ID, so we in the case
1451             # where we later format the patch, we format the patch with the added bug URL
1452             new_head = commit.id = git.rev_parse("HEAD")
1453
1454         if branch_name is not None:
1455             git.update_ref("-m", "bz add-url: adding references to %s" % bug.get_url(),
1456                            branch_name, new_head)
1457             git.symbolic_ref("HEAD", branch_name)
1458     except:
1459         print "Cleaning up back to original state on error"
1460         git.reset(orig_head, hard=True)
1461         if branch_name is not None:
1462             git.symbolic_ref("HEAD", branch_name)
1463         raise
1464
1465 def do_add_url(bug_reference, commit_or_revision_range):
1466     commits = get_commits(commit_or_revision_range)
1467
1468     bug = Bug.load(BugHandle.parse_or_die(bug_reference))
1469
1470     check_add_url(commits, bug.id, is_add_url=True)
1471
1472     print "Bug %d - %s" % (bug.id, bug.short_desc)
1473     print bug.get_url()
1474     print
1475
1476     found = False
1477     for commit in commits:
1478         if commit_needs_url(commit, bug.id):
1479             print commit.id[0:7], commit.subject
1480             found = True
1481         else:
1482             print "SKIPPING", commit.id[0:7], commit.subject
1483     if not found:
1484         sys.exit(0)
1485
1486     print
1487     if not prompt("Add bug URL to above commits?"):
1488         print "Aborting"
1489         sys.exit(0)
1490
1491     print
1492     add_url(bug, commits)
1493
1494 def do_apply(bug_reference):
1495     bug = Bug.load(BugHandle.parse_or_die(bug_reference),
1496                    attachmentdata=True)
1497
1498     print "Bug %d - %s" % (bug.id, bug.short_desc)
1499     print
1500
1501     instruction = False
1502     for patch in bug.patches:
1503         if patch.status == 'committed' or patch.status == 'rejected':
1504             print "Skipping, %s: %s" % (patch.status, patch.description)
1505             continue
1506
1507         print patch.description
1508         if not instruction == 'a':
1509             instruction = prompt("Apply?")
1510
1511         if not instruction:
1512             continue
1513
1514         print
1515
1516         handle, filename = tempfile.mkstemp(".patch", make_filename(patch.description) + "-")
1517         f = os.fdopen(handle, "w")
1518         f.write(patch.data)
1519         f.close()
1520
1521         try:
1522             if global_options.signoff:
1523                 process = git.am(filename, **{'_interactive': True, '3': True, 's': True})
1524             else:
1525                 process = git.am(filename, **{'_interactive': True, '3': True})
1526         except CalledProcessError:
1527             print "Patch left in %s" % filename
1528             break
1529
1530         os.remove(filename)
1531
1532         if global_options.add_url:
1533             # Slightly hacky, would be better to just commit right the first time
1534             commits = rev_list_commits("HEAD^!")
1535             add_url(bug, commits)
1536
1537 def strip_bug_url(bug, commit_body):
1538     # Strip off the trailing bug URLs we add with -u; we do this before
1539     # using commit body in as a comment; doing it by stripping right before
1540     # posting means that we are robust against someone running add-url first
1541     # and attach second.
1542     pattern = "\s*" + re.escape(bug.get_url()) + "\s*$"
1543     return re.sub(pattern, "", commit_body)
1544
1545 def edit_attachment_comment(bug, initial_description, initial_body):
1546     template = StringIO()
1547     template.write("# Attachment to Bug %d - %s\n\n" % (bug.id, bug.short_desc))
1548     template.write(initial_description)
1549     template.write("\n\n")
1550     template.write(initial_body)
1551     template.write("\n\n")
1552     if len(bug.patches) > 0:
1553         for patch in bug.patches:
1554             template.write("#Obsoletes: %d - %s\n" % (patch.attach_id, patch.description))
1555         template.write("\n")
1556
1557     template.write("# Current status: %s\n" % bug.bug_status)
1558     template.write("# Status: Needs Signoff\n")
1559     template.write("# Status: Signed Off\n")
1560     template.write("# Status: Passed QA\n")
1561     template.write("# Status: Pushed to Master\n")
1562     template.write("# Status: Pushed to Stable\n")
1563     template.write("\n")
1564
1565     template.write("""# Please edit the description (first line) and comment (other lines). Lines
1566 # starting with '#' will be ignored.  Delete everything to abort.
1567 """)
1568     if len(bug.patches) > 0:
1569         template.write("# To obsolete existing patches, uncomment the appropriate lines.\n")
1570
1571     lines = edit_template(template.getvalue())
1572
1573     obsoletes= []
1574     statuses= []
1575     def filter_line(line):
1576         m = re.match("^\s*Obsoletes\s*:\s*([\d]+)", line)
1577         if m:
1578             obsoletes.append(int(m.group(1)))
1579             return False
1580         m = re.match("^\s*Status\s*:\s*(.+)", line)
1581         if m:
1582             statuses.append(m.group(1))
1583             return False
1584         return True
1585
1586     lines = filter(filter_line, lines)
1587
1588     description, comment = split_subject_body(lines)
1589
1590     if description == "":
1591         die("Empty description, aborting")
1592
1593     return description, comment, obsoletes, statuses
1594
1595 def attach_commits(bug, commits, include_comments=True, edit_comments=False, status='none'):
1596     # We want to attach the patches in chronological order
1597     commits = list(commits)
1598     commits.reverse()
1599
1600     for commit in commits:
1601         filename = make_filename(commit.subject) + ".patch"
1602         patch = get_patch(commit)
1603         if include_comments:
1604             body = strip_bug_url(bug, get_body(commit))
1605         else:
1606             body = None
1607         if edit_comments:
1608             description, body, obsoletes, statuses = edit_attachment_comment(bug, commit.subject, body)
1609         else:
1610             description = commit.subject
1611             obsoletes = []
1612             statuses = []
1613
1614         if len(statuses) > 0:
1615             bug_changes = {}
1616             bug_changes['bug_status'] = statuses[0]
1617             bug.update(**bug_changes)
1618             print "Updated bug status to '%s'" % bug_changes['bug_status']
1619
1620         bug.create_patch(description, body, filename, patch, obsoletes=obsoletes, status=status)
1621
1622 def do_attach(*args):
1623     if len(args) == 1:
1624         commit_or_revision_range = args[0]
1625         commits = get_commits(commit_or_revision_range)
1626
1627         extracted = list(extract_and_collate_bugs(commits))
1628         if len(extracted) == 0:
1629             die("No bug references found in specified commits")
1630         elif len(extracted) > 1:
1631             # This could be sensible in the case of "attach updated patches
1632             # for all these commits", but for now, just make it an error
1633             die("Found multiple bug references specified commits:\n  " +
1634                 "\n  ".join((handle.get_url() for handle, _ in extracted)))
1635
1636         # extract_and_collate_bugs returns a list of commits that reference
1637         # the handle, but we ignore that - we want to attach all of the
1638         # specified commits, even if only some of the reference the bug
1639         handle, _ = extracted[0]
1640     else:
1641         bug_reference = args[0]
1642         commit_or_revision_range = args[1]
1643
1644         commits = get_commits(commit_or_revision_range)
1645         handle = BugHandle.parse_or_die(bug_reference)
1646
1647     bug = Bug.load(handle)
1648
1649     if global_options.add_url:
1650         check_add_url(commits, bug.id, is_add_url=False)
1651
1652     # We always want to prompt if the user has specified multiple attachments.
1653     # For the common case of one attachment don't prompt if we are going
1654     # to give them a chance to edit and abort anyways.
1655     if len(commits) > 1 or not global_options.edit:
1656         print "Bug %d - %s" % (bug.id, bug.short_desc)
1657         print
1658
1659         for commit in reversed(commits):
1660             print commit.id[0:7], commit.subject
1661
1662         print
1663         if not prompt("Attach?"):
1664             print "Aborting"
1665             sys.exit(0)
1666
1667     if global_options.add_url:
1668         add_url(bug, commits)
1669
1670     attach_commits(bug, commits, edit_comments=global_options.edit)
1671
1672 # Sort the patches in the bug into categories based on a set of Git
1673 # git commits that we're considering to be newly applied. Matching
1674 # is done on exact git subject <=> patch description matches.
1675 def filter_patches(bug, applied_commits):
1676     newly_applied_patches = dict() # maps to the commit object where it was applied
1677     obsoleted_patches = set()
1678     unapplied_patches = set()
1679
1680     applied_subjects = dict(((commit.subject, commit) for commit in applied_commits))
1681     seen_subjects = set()
1682
1683     # Work backwards so that the latest patch is considered applied, and older
1684     # patches with the same subject obsoleted.
1685     for patch in reversed(bug.patches):
1686         # Previously committted or rejected patches are never a match
1687         if patch.status == "committed" or patch.status == "rejected":
1688             continue
1689
1690         if patch.description in seen_subjects:
1691             obsoleted_patches.add(patch)
1692         elif patch.description in applied_subjects:
1693             newly_applied_patches[patch] = applied_subjects[patch.description]
1694             seen_subjects.add(patch)
1695         else:
1696             unapplied_patches.add(patch)
1697
1698     return newly_applied_patches, obsoleted_patches, unapplied_patches
1699
1700 def edit_bug(bug, applied_commits=None, fix_commits=None):
1701     if applied_commits is not None:
1702         newly_applied_patches, obsoleted_patches, unapplied_patches = filter_patches(bug, applied_commits)
1703         mark_resolved = len(unapplied_patches) == 0 and bug.bug_status != "RESOLVED"
1704     else:
1705         newly_applied_patches = obsoleted_patches = set()
1706         mark_resolved = fix_commits is not None
1707
1708     template = StringIO()
1709     template.write("# Bug %d - %s - %s" % (bug.id, bug.short_desc, bug.bug_status))
1710     if bug.bug_status == "RESOLVED":
1711         template.write(" - %s" % bug.resolution)
1712     template.write("\n")
1713     template.write("# %s\n" % bug.get_url())
1714     template.write("# Enter comment on following lines; delete everything to abort\n\n")
1715
1716     if fix_commits is not None:
1717         if len(fix_commits) == 1:
1718             template.write("The following fix has been pushed:\n")
1719         else:
1720             template.write("The following fixes have been pushed:\n")
1721         for commit in reversed(fix_commits):
1722             template.write(commit.id[0:7] + " " + commit.subject + "\n")
1723         template.write("\n")
1724
1725     for patch in bug.patches:
1726         if patch in newly_applied_patches:
1727             commit = newly_applied_patches[patch]
1728             template.write("Attachment %d pushed as %s - %s\n" % (patch.attach_id, commit.id[0:7], commit.subject))
1729
1730     template.write("# Status: Needs Signoff\n")
1731     template.write("# Status: Signed Off\n")
1732     template.write("# Status: Passed QA\n")
1733     template.write("# Status: Pushed to Master\n")
1734     template.write("# Status: Pushed to Stable\n")
1735     template.write("# Status: In Discussion\n")
1736
1737     if mark_resolved:
1738         template.write("# Comment to keep bug open\n")
1739     elif bug.bug_status == "RESOLVED":
1740         template.write("# Uncommment and edit to change resolution\n")
1741     else:
1742         template.write("# Uncomment to resolve bug\n")
1743     legal_resolutions = bug.server.legal_values('resolution')
1744     if legal_resolutions:
1745         # Require non-empty resolution. DUPLICATE, MOVED would need special support
1746         legal_resolutions = [x for x in legal_resolutions if x not in ('', 'DUPLICATE', 'MOVED')]
1747         template.write("# possible resolutions: %s\n" % abbreviation_help_string(legal_resolutions))
1748     if not mark_resolved:
1749         template.write("#")
1750     template.write("Resolution: FIXED\n")
1751
1752     if len(bug.patches) > 0:
1753         patches_have_status = any((patch.status is not None for patch in bug.patches))
1754         if patches_have_status:
1755             if len(newly_applied_patches) > 0 or len(obsoleted_patches) > 0:
1756                 template.write("\n# Lines below change patch status, unless commented out\n")
1757             else:
1758                 template.write("\n# To change patch status, uncomment below, edit 'committed' as appropriate.\n")
1759             legal_statuses = bug.server.legal_values('attachments.status')
1760             if legal_statuses:
1761                 legal_statuses.append('obsolete')
1762                 template.write("# possible statuses: %s\n" % abbreviation_help_string(legal_statuses))
1763
1764             for patch in bug.patches:
1765                 if patch in newly_applied_patches:
1766                     new_status = "committed"
1767                 elif patch in obsoleted_patches:
1768                     new_status = "obsolete"
1769                 else:
1770                     new_status = "#committed"
1771                 template.write("%s @%d - %s - %s\n" % (new_status, patch.attach_id, patch.description, patch.status))
1772         else:
1773             template.write("\n# To mark patches obsolete, uncomment below\n")
1774             for patch in bug.patches:
1775                 template.write("#obsolete @%d - %s\n" % (patch.attach_id, patch.description))
1776
1777         template.write("\n")
1778
1779     lines = edit_template(template.getvalue())
1780
1781     def filter_line(line):
1782         m = re.match("^\s*Resolution\s*:\s*(\S+)", line)
1783         if m:
1784             resolutions.append(m.group(1))
1785             return False
1786         m = re.match("^\s*Status\s*:\s*(.+)", line)
1787         if m:
1788             statuses.append(m.group(1))
1789             return False
1790         m = re.match("^\s*(\S+)\s*@\s*(\d+)", line)
1791         if m:
1792             status = m.group(1)
1793             changed_attachments[int(m.group(2))] = status
1794             return False
1795         return True
1796
1797     changed_attachments = {}
1798     resolutions = []
1799     statuses = []
1800
1801     lines = filter(filter_line, lines)
1802
1803     comment = "".join(lines).strip()
1804     bug_status = statuses[0] if len(statuses) > 0 else None
1805     resolution = resolutions[0] if len(resolutions) > 0 else None
1806
1807     if bug_status is None and resolution is None and len(changed_attachments) == 0 and comment == "":
1808         print "No changes, not editing Bug %d - %s" % (bug.id, bug.short_desc)
1809         return False
1810
1811     if fix_commits is not None:
1812         if global_options.add_url:
1813             # We don't want to add the URLs until the user has decided not to
1814             # cancel the operation. But the comment that the user edited
1815             # included commit IDs. If adding the URL changes the commit IDs
1816             # we need to replace them in the comment.
1817             old_ids = [(commit, commit.id[0:7]) for commit in fix_commits]
1818             add_url(bug, fix_commits)
1819             for commit, old_id in old_ids:
1820                 new_id = commit.id[0:7]
1821                 if new_id != old_id:
1822                     comment = comment.replace(old_id, new_id)
1823
1824     bug_changes = {}
1825     if bug_status is not None:
1826         bug_changes['bug_status'] = bug_status
1827
1828     if resolution is not None:
1829         if legal_resolutions:
1830             try:
1831                 resolution = expand_abbreviation(resolution, legal_resolutions)
1832             except ValueError:
1833                 die("Bad resolution: %s" % resolution)
1834         bug_changes['bug_status'] = 'RESOLVED'
1835         bug_changes['resolution'] = resolution
1836
1837     if comment != "":
1838         if len(bug_changes) == 0 and len(changed_attachments) == 1:
1839             # We can add the comment when we submit the attachment change.
1840             # Bugzilla will add a helpful notation ad we'll only send out
1841             # one set of email
1842             pass # We'll put the comment with the attachment
1843         else:
1844             bug_changes['comment'] = comment
1845
1846     # If we did the attachment updates first, we'd have to fetch a new
1847     # token hash for the bug, since they'll change it. But each attachment
1848     # has an individual token hash for just that attachment, so we can
1849     # do the attachment updates afterwards.
1850     if len(bug_changes) > 0:
1851         bug.update(**bug_changes)
1852
1853     for (attachment_id, status) in changed_attachments.iteritems():
1854         patch = None
1855         if patches_have_status:
1856             if legal_statuses:
1857                 try:
1858                     status = expand_abbreviation(status, legal_statuses)
1859                 except ValueError:
1860                     die("Bad patch status: %s" % status)
1861         else:
1862             if status != "obsolete":
1863                 die("Can't mark patch as '%s'; only obsolete is supported on %s" % (status,
1864                                                                                     bug.server.host))
1865         for p in bug.patches:
1866             if p.attach_id == attachment_id:
1867                 patch = p
1868         if not patch:
1869             die("%d is not a valid attachment ID for Bug %d" % (attachment_id, bug.id))
1870         attachment_changes = {}
1871         if comment != "" and not 'comment' in bug_changes: # See above
1872             attachment_changes['comment'] = comment
1873         if status == 'obsolete':
1874             attachment_changes['isobsolete'] = "1"
1875         else:
1876             attachment_changes['status'] = status
1877         bug.update_patch(patch, **attachment_changes)
1878         if status == 'obsolete':
1879             print "Marked attachment as obsolete: %s - %s " % (patch.attach_id, patch.description)
1880         else:
1881             print "Changed status of attachment to %s: %s - %s" % (status, patch.attach_id, patch.description)
1882
1883     if fix_commits is not None:
1884         attach_commits(bug, fix_commits, status='committed')
1885
1886     if resolution is not None:
1887         print "Resolved as %s bug %d - %s" % (resolution, bug.id, bug.short_desc)
1888     elif len(changed_attachments) > 0:
1889         print "Updated bug %d - %s" % (bug.id, bug.short_desc)
1890     else:
1891         print "Added comment to bug %d - %s" % (bug.id, bug.short_desc)
1892     print bug.get_url()
1893
1894     return True
1895
1896 LOG_BUG_REFERENCE = re.compile(r"""
1897 (\b[Ss]ee\s+(?:[^\s:/]+\s+){0,2})?
1898 (?:(https?://[^/]+/show_bug.cgi\?id=[^&\s]+)
1899      |
1900    [Bb]ug\s+\#?(\d+))
1901 """, re.VERBOSE | re.DOTALL)
1902
1903 def extract_bugs_from_string(str):
1904     refs = []
1905     for m in LOG_BUG_REFERENCE.finditer(str):
1906         bug_reference = None
1907
1908         # If something says "See http://bugzilla.gnome.org/..." or
1909         # "See mozilla bug http://bugzilla.mozilla.org/..." or "see
1910         # bug 12345" - anything like that - then it's probably talking
1911         # about some peripherally related bug. So, if the word see
1912         # occurs 0 to 2 words before the bug reference, we ignore it.
1913         if m.group(1) is not None:
1914             print "Skipping cross-reference '%s'" % m.group(0)
1915             continue
1916         if m.group(2) is not None:
1917             bug_reference = m.group(2)
1918         else:
1919             bug_reference = m.group(3)
1920
1921         try:
1922             yield BugHandle.parse(bug_reference)
1923         except BugParseError, e:
1924             print "WARNING: cannot resolve bug reference '%s'" % bug_reference
1925
1926 def extract_bugs_from_commit(commit):
1927     for handle in extract_bugs_from_string(commit.subject):
1928         yield handle
1929     for handle in extract_bugs_from_string(get_body(commit)):
1930         yield handle
1931
1932 # Yields bug, [<list of commits where it is referenced>] for each bug
1933 # referenced in the list of commits. The order of bugs is the same as the
1934 # order of their first reference in the list of commits
1935 def extract_and_collate_bugs(commits):
1936     bugs = []
1937     bug_to_commits = {}
1938
1939     for commit in commits:
1940         for handle in extract_bugs_from_commit(commit):
1941             if not handle in bug_to_commits:
1942                 bugs.append(handle)
1943                 bug_to_commits[handle] = []
1944             bug_to_commits[handle].append(commit)
1945
1946     for bug in bugs:
1947         yield bug, bug_to_commits[bug]
1948
1949 def do_edit(bug_reference_or_revision_range):
1950     try:
1951         handle = BugHandle.parse(bug_reference_or_revision_range)
1952         if global_options.pushed:
1953             die("--pushed can't be used together with a bug reference")
1954         if global_options.fix is not None:
1955             die("--fix requires commits to be specified")
1956         bug = Bug.load(handle)
1957         edit_bug(bug)
1958     except BugParseError, e:
1959         try:
1960             commits = get_commits(bug_reference_or_revision_range)
1961         except CalledProcessError:
1962             die("'%s' isn't a valid bug reference or revision range" % bug_reference_or_revision_range)
1963
1964         if global_options.fix is not None:
1965             handle = BugHandle.parse_or_die(global_options.fix)
1966             bug = Bug.load(handle)
1967             edit_bug(bug, fix_commits=commits)
1968         else:
1969             # Process from oldest to newest
1970             commits.reverse()
1971             for handle, commits in extract_and_collate_bugs(commits):
1972                 bug = Bug.load(handle)
1973                 if global_options.pushed:
1974                     edit_bug(bug, applied_commits=commits)
1975                 else:
1976                     edit_bug(bug)
1977
1978 PRODUCT_COMPONENT_HELP = """
1979
1980 Use:
1981
1982   git config bz.default-product <product>
1983   git config bz.default-component <component>
1984
1985 to configure a default product and/or component for this module."""
1986
1987 def do_file(*args):
1988     if len(args) == 1:
1989         product_component, commit_or_revision_range = None, args[0]
1990     else:
1991         product_component, commit_or_revision_range = args[0], args[1]
1992
1993     config = get_config(get_tracker())
1994
1995     if product_component:
1996         m = re.match("(?:([^/]+)/)?([^/]+)", product_component)
1997         if not m:
1998             die("'%s' is not a valid [<product>/]<component>" % product_component)
1999
2000         product = m.group(1)
2001         component = m.group(2)
2002
2003         if not product:
2004             product = get_default_product()
2005
2006         if not product:
2007             die("'%s' does not specify a product and no default product is configured" % product_component
2008                 + PRODUCT_COMPONENT_HELP)
2009     else:
2010         product = get_default_product()
2011         component = get_default_component()
2012
2013         if not product:
2014             die("[<product>/]<component> not specified and no default product is configured"
2015                 + PRODUCT_COMPONENT_HELP)
2016         if not component:
2017             die("[<product>/]<component> not specified and no default component is configured"
2018                 + PRODUCT_COMPONENT_HELP)
2019
2020     commits = get_commits(commit_or_revision_range)
2021
2022     if global_options.add_url:
2023         check_add_url(commits, is_add_url=False)
2024
2025     template = StringIO()
2026     if len(commits) == 1:
2027         template.write(commits[0].subject)
2028         template.write("\n")
2029     template.write("""
2030 # Please enter the summary (first line) and description (other lines). Lines
2031 # starting with '#' will be ignored.  Delete everything to abort.
2032 #
2033 # Product: %(product)s
2034 # Component: %(component)s
2035 # Patches to be attached:
2036 """ % { 'product': product, 'component': component })
2037     for commit in reversed(commits):
2038         template.write("#   " + commit.id[0:7] + " " + commit.subject + "\n")
2039
2040     lines = edit_template(template.getvalue())
2041
2042     summary, description = split_subject_body(lines)
2043
2044     if summary == "":
2045         die("Empty summary, aborting")
2046
2047     # If we have only one patch and no other description for the bug was
2048     # specified, use the body of the commit as the the description for
2049     # the bug rather than the descriptionfor the attachment
2050     include_comments=True
2051     if len(commits) == 1:
2052         if description == "":
2053             description = get_body(commits[0])
2054             include_comments = False
2055
2056     bug = Bug.create(get_tracker(), product, component, summary, description)
2057
2058     if global_options.add_url:
2059         add_url(bug, commits)
2060
2061     attach_commits(bug, commits, include_comments=include_comments)
2062
2063 def run_push(*args, **kwargs):
2064     # Predicting what 'git pushes' pushes based on the command line
2065     # would be extraordinarily complex, but the interactive output goes
2066     # to stderr and is somewhat ambiguous. We do the best we can parsing
2067     # it. git 1.6.4 adds --porcelain to push, so we can use that eventually.
2068     dry = kwargs['dry'] if 'dry' in kwargs else False
2069     options = dict()
2070     if dry:
2071         options['dry'] = True
2072     if global_options.force:
2073         options['force'] = True
2074     try:
2075         options['_return_stderr']=True
2076         out, err = git.push(*args, **options)
2077     except CalledProcessError:
2078         return
2079     if not dry:
2080         # Echo the output so the user gets feedback about what happened
2081         print >>sys.stderr, err
2082
2083     commits = []
2084     for line in err.strip().split("\n"):
2085         #
2086         # We only look for updates of existing branches; a much more complex
2087         # handling would be look for all commits that weren't pushed to a
2088         # remote branch. Hopefully the typical use of 'git bz push' is pushing
2089         # a single commit to master.
2090         #
2091         #                 e5ad33e..febe0d4             master  ->   master
2092         m = re.match(r"^\s*([a-f0-9]{6,}..[a-f0-9]{6,})\s+\S+\s*->\s*\S+\s*$", line)
2093         if m:
2094             branch_commits = get_commits(m.group(1))
2095             # Process from oldest to newest
2096             branch_commits.reverse()
2097             commits += branch_commits
2098
2099     # Remove duplicate commits
2100     seen_commit_ids = set()
2101     unique_commits = []
2102     for commit in commits:
2103         if not commit.id in seen_commit_ids:
2104             seen_commit_ids.add(commit.id)
2105             unique_commits.append(commit)
2106
2107     return unique_commits
2108
2109 def do_push(*args):
2110     if global_options.fix:
2111         handle = BugHandle.parse_or_die(global_options.fix)
2112         bug = Bug.load(handle)
2113
2114         # We need the user to confirm before we add the URLs to the commits
2115         # We need to add the URLs to the commits before we push
2116         # We need to push in order to find out what commits we are pushing
2117         # So, we push --dry first
2118         options = { 'dry' : True }
2119         commits = run_push(*args, **options)
2120         if edit_bug(bug, fix_commits=commits):
2121             run_push(*args)
2122     else:
2123         unique_commits = run_push(*args)
2124         for handle, commits in extract_and_collate_bugs(unique_commits):
2125             bug = Bug.load(handle)
2126             edit_bug(bug, commits)
2127
2128 ################################################################################
2129
2130 if len(sys.argv) > 1:
2131     command = sys.argv[1]
2132 else:
2133     command = ''
2134
2135 sys.argv[1:2] = []
2136
2137 parser = OptionParser()
2138 parser.add_option("-b", "--bugzilla", metavar="<host or alias>",
2139                   help="bug tracker to use")
2140
2141 def add_add_url_options():
2142     parser.add_option("-u", "--add-url", action="store_true",
2143                       help="rewrite commits to add the bug URL [default]")
2144     parser.add_option("-n", "--no-add-url", action="store_false", dest="add_url",
2145                       help="don't rewrite commits to add the bug URL")
2146
2147 def add_edit_option():
2148     parser.add_option("-e", "--edit", action="store_true",
2149                       help="allow editing the bugzilla comment")
2150
2151 def add_mail_option():
2152     parser.add_option("-m", "--mail", action="store_true",
2153                       help="send email")
2154
2155 def add_fix_option():
2156     parser.add_option("", "--fix", metavar="<bug reference>",
2157                       help="attach commits and close bug")
2158
2159 def add_signoff_option():
2160     parser.add_option("-s", "--signoff", action="store_true",
2161                       help="sign off when applying")
2162
2163 if command == 'add-url':
2164     parser.set_usage("git bz add-url [options] <bug reference> (<commit> | <revision range>)");
2165     min_args = max_args = 2
2166 elif command == 'apply':
2167     parser.set_usage("git bz apply [options] <bug reference>");
2168     add_add_url_options()
2169     add_signoff_option()
2170     min_args = max_args = 1
2171
2172 elif command == 'attach':
2173     parser.set_usage("git bz attach [options] [<bug reference>] (<commit> | <revision range>)");
2174     add_add_url_options()
2175     add_edit_option()
2176     add_mail_option()
2177     min_args = 1
2178     max_args = 3
2179
2180 elif command == 'edit':
2181     parser.set_usage("git bz edit [options] (<bug reference> | <commit> | <revision range>)");
2182     parser.add_option("", "--pushed", action="store_true",
2183                       help="pre-fill edit form treating the commits as pushed")
2184     add_add_url_options()
2185     add_fix_option()
2186     min_args = max_args = 1
2187 elif command == 'file':
2188     parser.set_usage("git bz file [options] [[<product>]]/<component>] (<commit> | <revision range>)");
2189     add_add_url_options()
2190     min_args = 1
2191     max_args = 2
2192 elif command == 'push':
2193     parser.set_usage("git bz push [options] [<repository> <refspec>...]");
2194     add_add_url_options()
2195     add_fix_option()
2196     parser.add_option("-f", "--force", action="store_true",
2197                       help="allow non-fast-forward commits")
2198     min_args = 0
2199     max_args = 1000 # no max
2200 else:
2201     print >>sys.stderr, "Usage: git bz [add-url|apply|attach|edit|file|push] [options]"
2202     sys.exit(1)
2203
2204 global_options, args = parser.parse_args()
2205
2206 if hasattr(global_options, 'add_url') and global_options.add_url is None:
2207     global_options.add_url = get_add_url()
2208
2209 if len(args) < min_args or len(args) > max_args:
2210     parser.print_usage()
2211     sys.exit(1)
2212
2213 if command == 'add-url':
2214     do_add_url(*args)
2215 elif command == 'apply':
2216     do_apply(*args)
2217 elif command == 'attach':
2218     do_attach(*args)
2219
2220 elif command == 'edit':
2221     if global_options.pushed:
2222         exit
2223     do_edit(*args)
2224 elif command == 'file':
2225     do_file(*args)
2226 elif command == 'push':
2227     do_push(*args)
2228
2229 sys.exit(0)