eggs/mercurial-1.7.3-py2.6-linux-x86_64.egg/hgext/bugzilla.py
changeset 69 c6bca38c1cbf
equal deleted inserted replaced
68:5ff1fc726848 69:c6bca38c1cbf
       
     1 # bugzilla.py - bugzilla integration for mercurial
       
     2 #
       
     3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
       
     4 #
       
     5 # This software may be used and distributed according to the terms of the
       
     6 # GNU General Public License version 2 or any later version.
       
     7 
       
     8 '''hooks for integrating with the Bugzilla bug tracker
       
     9 
       
    10 This hook extension adds comments on bugs in Bugzilla when changesets
       
    11 that refer to bugs by Bugzilla ID are seen. The hook does not change
       
    12 bug status.
       
    13 
       
    14 The hook updates the Bugzilla database directly. Only Bugzilla
       
    15 installations using MySQL are supported.
       
    16 
       
    17 The hook relies on a Bugzilla script to send bug change notification
       
    18 emails. That script changes between Bugzilla versions; the
       
    19 'processmail' script used prior to 2.18 is replaced in 2.18 and
       
    20 subsequent versions by 'config/sendbugmail.pl'. Note that these will
       
    21 be run by Mercurial as the user pushing the change; you will need to
       
    22 ensure the Bugzilla install file permissions are set appropriately.
       
    23 
       
    24 The extension is configured through three different configuration
       
    25 sections. These keys are recognized in the [bugzilla] section:
       
    26 
       
    27 host
       
    28   Hostname of the MySQL server holding the Bugzilla database.
       
    29 
       
    30 db
       
    31   Name of the Bugzilla database in MySQL. Default 'bugs'.
       
    32 
       
    33 user
       
    34   Username to use to access MySQL server. Default 'bugs'.
       
    35 
       
    36 password
       
    37   Password to use to access MySQL server.
       
    38 
       
    39 timeout
       
    40   Database connection timeout (seconds). Default 5.
       
    41 
       
    42 version
       
    43   Bugzilla version. Specify '3.0' for Bugzilla versions 3.0 and later,
       
    44   '2.18' for Bugzilla versions from 2.18 and '2.16' for versions prior
       
    45   to 2.18.
       
    46 
       
    47 bzuser
       
    48   Fallback Bugzilla user name to record comments with, if changeset
       
    49   committer cannot be found as a Bugzilla user.
       
    50 
       
    51 bzdir
       
    52    Bugzilla install directory. Used by default notify. Default
       
    53    '/var/www/html/bugzilla'.
       
    54 
       
    55 notify
       
    56   The command to run to get Bugzilla to send bug change notification
       
    57   emails. Substitutes from a map with 3 keys, 'bzdir', 'id' (bug id)
       
    58   and 'user' (committer bugzilla email). Default depends on version;
       
    59   from 2.18 it is "cd %(bzdir)s && perl -T contrib/sendbugmail.pl
       
    60   %(id)s %(user)s".
       
    61 
       
    62 regexp
       
    63   Regular expression to match bug IDs in changeset commit message.
       
    64   Must contain one "()" group. The default expression matches 'Bug
       
    65   1234', 'Bug no. 1234', 'Bug number 1234', 'Bugs 1234,5678', 'Bug
       
    66   1234 and 5678' and variations thereof. Matching is case insensitive.
       
    67 
       
    68 style
       
    69   The style file to use when formatting comments.
       
    70 
       
    71 template
       
    72   Template to use when formatting comments. Overrides style if
       
    73   specified. In addition to the usual Mercurial keywords, the
       
    74   extension specifies::
       
    75 
       
    76     {bug}       The Bugzilla bug ID.
       
    77     {root}      The full pathname of the Mercurial repository.
       
    78     {webroot}   Stripped pathname of the Mercurial repository.
       
    79     {hgweb}     Base URL for browsing Mercurial repositories.
       
    80 
       
    81   Default 'changeset {node|short} in repo {root} refers '
       
    82           'to bug {bug}.\\ndetails:\\n\\t{desc|tabindent}'
       
    83 
       
    84 strip
       
    85   The number of slashes to strip from the front of {root} to produce
       
    86   {webroot}. Default 0.
       
    87 
       
    88 usermap
       
    89   Path of file containing Mercurial committer ID to Bugzilla user ID
       
    90   mappings. If specified, the file should contain one mapping per
       
    91   line, "committer"="Bugzilla user". See also the [usermap] section.
       
    92 
       
    93 The [usermap] section is used to specify mappings of Mercurial
       
    94 committer ID to Bugzilla user ID. See also [bugzilla].usermap.
       
    95 "committer"="Bugzilla user"
       
    96 
       
    97 Finally, the [web] section supports one entry:
       
    98 
       
    99 baseurl
       
   100   Base URL for browsing Mercurial repositories. Reference from
       
   101   templates as {hgweb}.
       
   102 
       
   103 Activating the extension::
       
   104 
       
   105     [extensions]
       
   106     bugzilla =
       
   107 
       
   108     [hooks]
       
   109     # run bugzilla hook on every change pulled or pushed in here
       
   110     incoming.bugzilla = python:hgext.bugzilla.hook
       
   111 
       
   112 Example configuration:
       
   113 
       
   114 This example configuration is for a collection of Mercurial
       
   115 repositories in /var/local/hg/repos/ used with a local Bugzilla 3.2
       
   116 installation in /opt/bugzilla-3.2. ::
       
   117 
       
   118     [bugzilla]
       
   119     host=localhost
       
   120     password=XYZZY
       
   121     version=3.0
       
   122     bzuser=unknown@domain.com
       
   123     bzdir=/opt/bugzilla-3.2
       
   124     template=Changeset {node|short} in {root|basename}.
       
   125              {hgweb}/{webroot}/rev/{node|short}\\n
       
   126              {desc}\\n
       
   127     strip=5
       
   128 
       
   129     [web]
       
   130     baseurl=http://dev.domain.com/hg
       
   131 
       
   132     [usermap]
       
   133     user@emaildomain.com=user.name@bugzilladomain.com
       
   134 
       
   135 Commits add a comment to the Bugzilla bug record of the form::
       
   136 
       
   137     Changeset 3b16791d6642 in repository-name.
       
   138     http://dev.domain.com/hg/repository-name/rev/3b16791d6642
       
   139 
       
   140     Changeset commit comment. Bug 1234.
       
   141 '''
       
   142 
       
   143 from mercurial.i18n import _
       
   144 from mercurial.node import short
       
   145 from mercurial import cmdutil, templater, util
       
   146 import re, time
       
   147 
       
   148 MySQLdb = None
       
   149 
       
   150 def buglist(ids):
       
   151     return '(' + ','.join(map(str, ids)) + ')'
       
   152 
       
   153 class bugzilla_2_16(object):
       
   154     '''support for bugzilla version 2.16.'''
       
   155 
       
   156     def __init__(self, ui):
       
   157         self.ui = ui
       
   158         host = self.ui.config('bugzilla', 'host', 'localhost')
       
   159         user = self.ui.config('bugzilla', 'user', 'bugs')
       
   160         passwd = self.ui.config('bugzilla', 'password')
       
   161         db = self.ui.config('bugzilla', 'db', 'bugs')
       
   162         timeout = int(self.ui.config('bugzilla', 'timeout', 5))
       
   163         usermap = self.ui.config('bugzilla', 'usermap')
       
   164         if usermap:
       
   165             self.ui.readconfig(usermap, sections=['usermap'])
       
   166         self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
       
   167                      (host, db, user, '*' * len(passwd)))
       
   168         self.conn = MySQLdb.connect(host=host, user=user, passwd=passwd,
       
   169                                     db=db, connect_timeout=timeout)
       
   170         self.cursor = self.conn.cursor()
       
   171         self.longdesc_id = self.get_longdesc_id()
       
   172         self.user_ids = {}
       
   173         self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
       
   174 
       
   175     def run(self, *args, **kwargs):
       
   176         '''run a query.'''
       
   177         self.ui.note(_('query: %s %s\n') % (args, kwargs))
       
   178         try:
       
   179             self.cursor.execute(*args, **kwargs)
       
   180         except MySQLdb.MySQLError:
       
   181             self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
       
   182             raise
       
   183 
       
   184     def get_longdesc_id(self):
       
   185         '''get identity of longdesc field'''
       
   186         self.run('select fieldid from fielddefs where name = "longdesc"')
       
   187         ids = self.cursor.fetchall()
       
   188         if len(ids) != 1:
       
   189             raise util.Abort(_('unknown database schema'))
       
   190         return ids[0][0]
       
   191 
       
   192     def filter_real_bug_ids(self, ids):
       
   193         '''filter not-existing bug ids from list.'''
       
   194         self.run('select bug_id from bugs where bug_id in %s' % buglist(ids))
       
   195         return sorted([c[0] for c in self.cursor.fetchall()])
       
   196 
       
   197     def filter_unknown_bug_ids(self, node, ids):
       
   198         '''filter bug ids from list that already refer to this changeset.'''
       
   199 
       
   200         self.run('''select bug_id from longdescs where
       
   201                     bug_id in %s and thetext like "%%%s%%"''' %
       
   202                  (buglist(ids), short(node)))
       
   203         unknown = set(ids)
       
   204         for (id,) in self.cursor.fetchall():
       
   205             self.ui.status(_('bug %d already knows about changeset %s\n') %
       
   206                            (id, short(node)))
       
   207             unknown.discard(id)
       
   208         return sorted(unknown)
       
   209 
       
   210     def notify(self, ids, committer):
       
   211         '''tell bugzilla to send mail.'''
       
   212 
       
   213         self.ui.status(_('telling bugzilla to send mail:\n'))
       
   214         (user, userid) = self.get_bugzilla_user(committer)
       
   215         for id in ids:
       
   216             self.ui.status(_('  bug %s\n') % id)
       
   217             cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
       
   218             bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
       
   219             try:
       
   220                 # Backwards-compatible with old notify string, which
       
   221                 # took one string. This will throw with a new format
       
   222                 # string.
       
   223                 cmd = cmdfmt % id
       
   224             except TypeError:
       
   225                 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
       
   226             self.ui.note(_('running notify command %s\n') % cmd)
       
   227             fp = util.popen('(%s) 2>&1' % cmd)
       
   228             out = fp.read()
       
   229             ret = fp.close()
       
   230             if ret:
       
   231                 self.ui.warn(out)
       
   232                 raise util.Abort(_('bugzilla notify command %s') %
       
   233                                  util.explain_exit(ret)[0])
       
   234         self.ui.status(_('done\n'))
       
   235 
       
   236     def get_user_id(self, user):
       
   237         '''look up numeric bugzilla user id.'''
       
   238         try:
       
   239             return self.user_ids[user]
       
   240         except KeyError:
       
   241             try:
       
   242                 userid = int(user)
       
   243             except ValueError:
       
   244                 self.ui.note(_('looking up user %s\n') % user)
       
   245                 self.run('''select userid from profiles
       
   246                             where login_name like %s''', user)
       
   247                 all = self.cursor.fetchall()
       
   248                 if len(all) != 1:
       
   249                     raise KeyError(user)
       
   250                 userid = int(all[0][0])
       
   251             self.user_ids[user] = userid
       
   252             return userid
       
   253 
       
   254     def map_committer(self, user):
       
   255         '''map name of committer to bugzilla user name.'''
       
   256         for committer, bzuser in self.ui.configitems('usermap'):
       
   257             if committer.lower() == user.lower():
       
   258                 return bzuser
       
   259         return user
       
   260 
       
   261     def get_bugzilla_user(self, committer):
       
   262         '''see if committer is a registered bugzilla user. Return
       
   263         bugzilla username and userid if so. If not, return default
       
   264         bugzilla username and userid.'''
       
   265         user = self.map_committer(committer)
       
   266         try:
       
   267             userid = self.get_user_id(user)
       
   268         except KeyError:
       
   269             try:
       
   270                 defaultuser = self.ui.config('bugzilla', 'bzuser')
       
   271                 if not defaultuser:
       
   272                     raise util.Abort(_('cannot find bugzilla user id for %s') %
       
   273                                      user)
       
   274                 userid = self.get_user_id(defaultuser)
       
   275                 user = defaultuser
       
   276             except KeyError:
       
   277                 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
       
   278                                  (user, defaultuser))
       
   279         return (user, userid)
       
   280 
       
   281     def add_comment(self, bugid, text, committer):
       
   282         '''add comment to bug. try adding comment as committer of
       
   283         changeset, otherwise as default bugzilla user.'''
       
   284         (user, userid) = self.get_bugzilla_user(committer)
       
   285         now = time.strftime('%Y-%m-%d %H:%M:%S')
       
   286         self.run('''insert into longdescs
       
   287                     (bug_id, who, bug_when, thetext)
       
   288                     values (%s, %s, %s, %s)''',
       
   289                  (bugid, userid, now, text))
       
   290         self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
       
   291                     values (%s, %s, %s, %s)''',
       
   292                  (bugid, userid, now, self.longdesc_id))
       
   293         self.conn.commit()
       
   294 
       
   295 class bugzilla_2_18(bugzilla_2_16):
       
   296     '''support for bugzilla 2.18 series.'''
       
   297 
       
   298     def __init__(self, ui):
       
   299         bugzilla_2_16.__init__(self, ui)
       
   300         self.default_notify = \
       
   301             "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
       
   302 
       
   303 class bugzilla_3_0(bugzilla_2_18):
       
   304     '''support for bugzilla 3.0 series.'''
       
   305 
       
   306     def __init__(self, ui):
       
   307         bugzilla_2_18.__init__(self, ui)
       
   308 
       
   309     def get_longdesc_id(self):
       
   310         '''get identity of longdesc field'''
       
   311         self.run('select id from fielddefs where name = "longdesc"')
       
   312         ids = self.cursor.fetchall()
       
   313         if len(ids) != 1:
       
   314             raise util.Abort(_('unknown database schema'))
       
   315         return ids[0][0]
       
   316 
       
   317 class bugzilla(object):
       
   318     # supported versions of bugzilla. different versions have
       
   319     # different schemas.
       
   320     _versions = {
       
   321         '2.16': bugzilla_2_16,
       
   322         '2.18': bugzilla_2_18,
       
   323         '3.0':  bugzilla_3_0
       
   324         }
       
   325 
       
   326     _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
       
   327                        r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)')
       
   328 
       
   329     _bz = None
       
   330 
       
   331     def __init__(self, ui, repo):
       
   332         self.ui = ui
       
   333         self.repo = repo
       
   334 
       
   335     def bz(self):
       
   336         '''return object that knows how to talk to bugzilla version in
       
   337         use.'''
       
   338 
       
   339         if bugzilla._bz is None:
       
   340             bzversion = self.ui.config('bugzilla', 'version')
       
   341             try:
       
   342                 bzclass = bugzilla._versions[bzversion]
       
   343             except KeyError:
       
   344                 raise util.Abort(_('bugzilla version %s not supported') %
       
   345                                  bzversion)
       
   346             bugzilla._bz = bzclass(self.ui)
       
   347         return bugzilla._bz
       
   348 
       
   349     def __getattr__(self, key):
       
   350         return getattr(self.bz(), key)
       
   351 
       
   352     _bug_re = None
       
   353     _split_re = None
       
   354 
       
   355     def find_bug_ids(self, ctx):
       
   356         '''find valid bug ids that are referred to in changeset
       
   357         comments and that do not already have references to this
       
   358         changeset.'''
       
   359 
       
   360         if bugzilla._bug_re is None:
       
   361             bugzilla._bug_re = re.compile(
       
   362                 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
       
   363                 re.IGNORECASE)
       
   364             bugzilla._split_re = re.compile(r'\D+')
       
   365         start = 0
       
   366         ids = set()
       
   367         while True:
       
   368             m = bugzilla._bug_re.search(ctx.description(), start)
       
   369             if not m:
       
   370                 break
       
   371             start = m.end()
       
   372             for id in bugzilla._split_re.split(m.group(1)):
       
   373                 if not id:
       
   374                     continue
       
   375                 ids.add(int(id))
       
   376         if ids:
       
   377             ids = self.filter_real_bug_ids(ids)
       
   378         if ids:
       
   379             ids = self.filter_unknown_bug_ids(ctx.node(), ids)
       
   380         return ids
       
   381 
       
   382     def update(self, bugid, ctx):
       
   383         '''update bugzilla bug with reference to changeset.'''
       
   384 
       
   385         def webroot(root):
       
   386             '''strip leading prefix of repo root and turn into
       
   387             url-safe path.'''
       
   388             count = int(self.ui.config('bugzilla', 'strip', 0))
       
   389             root = util.pconvert(root)
       
   390             while count > 0:
       
   391                 c = root.find('/')
       
   392                 if c == -1:
       
   393                     break
       
   394                 root = root[c + 1:]
       
   395                 count -= 1
       
   396             return root
       
   397 
       
   398         mapfile = self.ui.config('bugzilla', 'style')
       
   399         tmpl = self.ui.config('bugzilla', 'template')
       
   400         t = cmdutil.changeset_templater(self.ui, self.repo,
       
   401                                         False, None, mapfile, False)
       
   402         if not mapfile and not tmpl:
       
   403             tmpl = _('changeset {node|short} in repo {root} refers '
       
   404                      'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
       
   405         if tmpl:
       
   406             tmpl = templater.parsestring(tmpl, quoted=False)
       
   407             t.use_template(tmpl)
       
   408         self.ui.pushbuffer()
       
   409         t.show(ctx, changes=ctx.changeset(),
       
   410                bug=str(bugid),
       
   411                hgweb=self.ui.config('web', 'baseurl'),
       
   412                root=self.repo.root,
       
   413                webroot=webroot(self.repo.root))
       
   414         data = self.ui.popbuffer()
       
   415         self.add_comment(bugid, data, util.email(ctx.user()))
       
   416 
       
   417 def hook(ui, repo, hooktype, node=None, **kwargs):
       
   418     '''add comment to bugzilla for each changeset that refers to a
       
   419     bugzilla bug id. only add a comment once per bug, so same change
       
   420     seen multiple times does not fill bug with duplicate data.'''
       
   421     try:
       
   422         import MySQLdb as mysql
       
   423         global MySQLdb
       
   424         MySQLdb = mysql
       
   425     except ImportError, err:
       
   426         raise util.Abort(_('python mysql support not available: %s') % err)
       
   427 
       
   428     if node is None:
       
   429         raise util.Abort(_('hook type %s does not pass a changeset id') %
       
   430                          hooktype)
       
   431     try:
       
   432         bz = bugzilla(ui, repo)
       
   433         ctx = repo[node]
       
   434         ids = bz.find_bug_ids(ctx)
       
   435         if ids:
       
   436             for id in ids:
       
   437                 bz.update(id, ctx)
       
   438             bz.notify(ids, util.email(ctx.user()))
       
   439     except MySQLdb.MySQLError, err:
       
   440         raise util.Abort(_('database error: %s') % err.args[1])
       
   441