hgext/bugzilla.py
changeset 16221 4fc9fcd991c1
parent 16193 b468cea3f29d
child 16222 d7b7b453c035
equal deleted inserted replaced
16218:81a1a00f5738 16221:4fc9fcd991c1
     1 # bugzilla.py - bugzilla integration for mercurial
     1 # bugzilla.py - bugzilla integration for mercurial
     2 #
     2 #
     3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
     3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
     4 # Copyright 2011 Jim Hague <jim.hague@acm.org>
     4 # Copyright 2011-2 Jim Hague <jim.hague@acm.org>
     5 #
     5 #
     6 # This software may be used and distributed according to the terms of the
     6 # This software may be used and distributed according to the terms of the
     7 # GNU General Public License version 2 or any later version.
     7 # GNU General Public License version 2 or any later version.
     8 
     8 
     9 '''hooks for integrating with the Bugzilla bug tracker
     9 '''hooks for integrating with the Bugzilla bug tracker
   272             if committer.lower() == user.lower():
   272             if committer.lower() == user.lower():
   273                 return bzuser
   273                 return bzuser
   274         return user
   274         return user
   275 
   275 
   276     # Methods to be implemented by access classes.
   276     # Methods to be implemented by access classes.
   277     def filter_real_bug_ids(self, ids):
   277     #
   278         '''remove bug IDs that do not exist in Bugzilla from set.'''
   278     # 'bugs' is a dict keyed on bug id, where values are a dict holding
       
   279     # updates to bug state. Currently no states are recognised, but this
       
   280     # will change soon.
       
   281     def filter_real_bug_ids(self, bugs):
       
   282         '''remove bug IDs that do not exist in Bugzilla from bugs.'''
   279         pass
   283         pass
   280 
   284 
   281     def filter_cset_known_bug_ids(self, node, ids):
   285     def filter_cset_known_bug_ids(self, node, bugs):
   282         '''remove bug IDs where node occurs in comment text from set.'''
   286         '''remove bug IDs where node occurs in comment text from bugs.'''
   283         pass
   287         pass
   284 
   288 
   285     def add_comment(self, bugid, text, committer):
   289     def updatebug(self, bugid, newstate, text, committer):
   286         '''add comment to bug.
   290         '''update the specified bug. Add comment text and set new states.
   287 
   291 
   288         If possible add the comment as being from the committer of
   292         If possible add the comment as being from the committer of
   289         the changeset. Otherwise use the default Bugzilla user.
   293         the changeset. Otherwise use the default Bugzilla user.
   290         '''
   294         '''
   291         pass
   295         pass
   292 
   296 
   293     def notify(self, ids, committer):
   297     def notify(self, bugs, committer):
   294         '''Force sending of Bugzilla notification emails.'''
   298         '''Force sending of Bugzilla notification emails.
       
   299 
       
   300         Only required if the access method does not trigger notification
       
   301         emails automatically.
       
   302         '''
   295         pass
   303         pass
   296 
   304 
   297 # Bugzilla via direct access to MySQL database.
   305 # Bugzilla via direct access to MySQL database.
   298 class bzmysql(bzaccess):
   306 class bzmysql(bzaccess):
   299     '''Support for direct MySQL access to Bugzilla.
   307     '''Support for direct MySQL access to Bugzilla.
   351         ids = self.cursor.fetchall()
   359         ids = self.cursor.fetchall()
   352         if len(ids) != 1:
   360         if len(ids) != 1:
   353             raise util.Abort(_('unknown database schema'))
   361             raise util.Abort(_('unknown database schema'))
   354         return ids[0][0]
   362         return ids[0][0]
   355 
   363 
   356     def filter_real_bug_ids(self, ids):
   364     def filter_real_bug_ids(self, bugs):
   357         '''filter not-existing bug ids from set.'''
   365         '''filter not-existing bugs from set.'''
   358         self.run('select bug_id from bugs where bug_id in %s' %
   366         self.run('select bug_id from bugs where bug_id in %s' %
   359                  bzmysql.sql_buglist(ids))
   367                  bzmysql.sql_buglist(bugs.keys()))
   360         return set([c[0] for c in self.cursor.fetchall()])
   368         existing = [id for (id,) in self.cursor.fetchall()]
   361 
   369         for id in bugs.keys():
   362     def filter_cset_known_bug_ids(self, node, ids):
   370             if id not in existing:
       
   371                 self.ui.status(_('bug %d does not exist\n') % id)
       
   372                 del bugs[id]
       
   373 
       
   374     def filter_cset_known_bug_ids(self, node, bugs):
   363         '''filter bug ids that already refer to this changeset from set.'''
   375         '''filter bug ids that already refer to this changeset from set.'''
   364 
       
   365         self.run('''select bug_id from longdescs where
   376         self.run('''select bug_id from longdescs where
   366                     bug_id in %s and thetext like "%%%s%%"''' %
   377                     bug_id in %s and thetext like "%%%s%%"''' %
   367                  (bzmysql.sql_buglist(ids), short(node)))
   378                  (bzmysql.sql_buglist(bugs.keys()), short(node)))
   368         for (id,) in self.cursor.fetchall():
   379         for (id,) in self.cursor.fetchall():
   369             self.ui.status(_('bug %d already knows about changeset %s\n') %
   380             self.ui.status(_('bug %d already knows about changeset %s\n') %
   370                            (id, short(node)))
   381                            (id, short(node)))
   371             ids.discard(id)
   382             del bugs[id]
   372         return ids
   383 
   373 
   384     def notify(self, bugs, committer):
   374     def notify(self, ids, committer):
       
   375         '''tell bugzilla to send mail.'''
   385         '''tell bugzilla to send mail.'''
   376 
       
   377         self.ui.status(_('telling bugzilla to send mail:\n'))
   386         self.ui.status(_('telling bugzilla to send mail:\n'))
   378         (user, userid) = self.get_bugzilla_user(committer)
   387         (user, userid) = self.get_bugzilla_user(committer)
   379         for id in ids:
   388         for id in bugs.keys():
   380             self.ui.status(_('  bug %s\n') % id)
   389             self.ui.status(_('  bug %s\n') % id)
   381             cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
   390             cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
   382             bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
   391             bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
   383             try:
   392             try:
   384                 # Backwards-compatible with old notify string, which
   393                 # Backwards-compatible with old notify string, which
   433             except KeyError:
   442             except KeyError:
   434                 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
   443                 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
   435                                  (user, defaultuser))
   444                                  (user, defaultuser))
   436         return (user, userid)
   445         return (user, userid)
   437 
   446 
   438     def add_comment(self, bugid, text, committer):
   447     def updatebug(self, bugid, newstate, text, committer):
   439         '''add comment to bug. try adding comment as committer of
   448         '''update bug state with comment text.
   440         changeset, otherwise as default bugzilla user.'''
   449 
       
   450         Try adding comment as committer of changeset, otherwise as
       
   451         default bugzilla user.'''
   441         (user, userid) = self.get_bugzilla_user(committer)
   452         (user, userid) = self.get_bugzilla_user(committer)
   442         now = time.strftime('%Y-%m-%d %H:%M:%S')
   453         now = time.strftime('%Y-%m-%d %H:%M:%S')
   443         self.run('''insert into longdescs
   454         self.run('''insert into longdescs
   444                     (bug_id, who, bug_when, thetext)
   455                     (bug_id, who, bug_when, thetext)
   445                     values (%s, %s, %s, %s)''',
   456                     values (%s, %s, %s, %s)''',
   574         else:
   585         else:
   575             return cookietransport()
   586             return cookietransport()
   576 
   587 
   577     def get_bug_comments(self, id):
   588     def get_bug_comments(self, id):
   578         """Return a string with all comment text for a bug."""
   589         """Return a string with all comment text for a bug."""
   579         c = self.bzproxy.Bug.comments(dict(ids=[id]))
   590         c = self.bzproxy.Bug.comments(dict(ids=[id], include_fields=['text']))
   580         return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
   591         return ''.join([t['text'] for t in c['bugs'][str(id)]['comments']])
   581 
   592 
   582     def filter_real_bug_ids(self, ids):
   593     def filter_real_bug_ids(self, bugs):
   583         res = set()
   594         probe = self.bzproxy.Bug.get(dict(ids=sorted(bugs.keys()),
   584         bugs = self.bzproxy.Bug.get(dict(ids=sorted(ids), permissive=True))
   595                                           include_fields=[],
   585         for bug in bugs['bugs']:
   596                                           permissive=True))
   586             res.add(bug['id'])
   597         for badbug in probe['faults']:
   587         return res
   598             id = badbug['id']
   588 
   599             self.ui.status(_('bug %d does not exist\n') % id)
   589     def filter_cset_known_bug_ids(self, node, ids):
   600             del bugs[id]
   590         for id in sorted(ids):
   601 
       
   602     def filter_cset_known_bug_ids(self, node, bugs):
       
   603         for id in sorted(bugs.keys()):
   591             if self.get_bug_comments(id).find(short(node)) != -1:
   604             if self.get_bug_comments(id).find(short(node)) != -1:
   592                 self.ui.status(_('bug %d already knows about changeset %s\n') %
   605                 self.ui.status(_('bug %d already knows about changeset %s\n') %
   593                                (id, short(node)))
   606                                (id, short(node)))
   594                 ids.discard(id)
   607                 del bugs[id]
   595         return ids
   608 
   596 
   609     def updatebug(self, bugid, newstate, text, committer):
   597     def add_comment(self, bugid, text, committer):
   610         args = dict(id=bugid, comment=text)
   598         self.bzproxy.Bug.add_comment(dict(id=bugid, comment=text))
   611         self.bzproxy.Bug.add_comment(args)
   599 
   612 
   600 class bzxmlrpcemail(bzxmlrpc):
   613 class bzxmlrpcemail(bzxmlrpc):
   601     """Read data from Bugzilla via XMLRPC, send updates via email.
   614     """Read data from Bugzilla via XMLRPC, send updates via email.
   602 
   615 
   603     Advantages of sending updates via email:
   616     Advantages of sending updates via email:
   645         msg['To'] = bzemail
   658         msg['To'] = bzemail
   646         msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
   659         msg['Subject'] = mail.headencode(self.ui, "Bug modification", _charsets)
   647         sendmail = mail.connect(self.ui)
   660         sendmail = mail.connect(self.ui)
   648         sendmail(user, bzemail, msg.as_string())
   661         sendmail(user, bzemail, msg.as_string())
   649 
   662 
   650     def add_comment(self, bugid, text, committer):
   663     def updatebug(self, bugid, newstate, text, committer):
   651         self.send_bug_modify_email(bugid, [], text, committer)
   664         self.send_bug_modify_email(bugid, [], text, committer)
   652 
   665 
   653 class bugzilla(object):
   666 class bugzilla(object):
   654     # supported versions of bugzilla. different versions have
   667     # supported versions of bugzilla. different versions have
   655     # different schemas.
   668     # different schemas.
   688         return getattr(self.bz(), key)
   701         return getattr(self.bz(), key)
   689 
   702 
   690     _bug_re = None
   703     _bug_re = None
   691     _split_re = None
   704     _split_re = None
   692 
   705 
   693     def find_bug_ids(self, ctx):
   706     def find_bugs(self, ctx):
   694         '''return set of integer bug IDs from commit comment.
   707         '''return bugs dictionary created from commit comment.
   695 
   708 
   696         Extract bug IDs from changeset comments. Filter out any that are
   709         Extract bug info from changeset comments. Filter out any that are
   697         not known to Bugzilla, and any that already have a reference to
   710         not known to Bugzilla, and any that already have a reference to
   698         the given changeset in their comments.
   711         the given changeset in their comments.
   699         '''
   712         '''
   700         if bugzilla._bug_re is None:
   713         if bugzilla._bug_re is None:
   701             bugzilla._bug_re = re.compile(
   714             bugzilla._bug_re = re.compile(
   702                 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
   715                 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
   703                 re.IGNORECASE)
   716                 re.IGNORECASE)
   704             bugzilla._split_re = re.compile(r'\D+')
   717             bugzilla._split_re = re.compile(r'\D+')
   705         start = 0
   718         start = 0
   706         ids = set()
   719         bugs = {}
   707         while True:
   720         while True:
   708             m = bugzilla._bug_re.search(ctx.description(), start)
   721             m = bugzilla._bug_re.search(ctx.description(), start)
   709             if not m:
   722             if not m:
   710                 break
   723                 break
   711             start = m.end()
   724             start = m.end()
   712             for id in bugzilla._split_re.split(m.group(1)):
   725             for id in bugzilla._split_re.split(m.group(1)):
   713                 if not id:
   726                 if not id:
   714                     continue
   727                     continue
   715                 ids.add(int(id))
   728                 bugs[int(id)] = {}
   716         if ids:
   729         if bugs:
   717             ids = self.filter_real_bug_ids(ids)
   730             self.filter_real_bug_ids(bugs)
   718         if ids:
   731         if bugs:
   719             ids = self.filter_cset_known_bug_ids(ctx.node(), ids)
   732             self.filter_cset_known_bug_ids(ctx.node(), bugs)
   720         return ids
   733         return bugs
   721 
   734 
   722     def update(self, bugid, ctx):
   735     def update(self, bugid, newstate, ctx):
   723         '''update bugzilla bug with reference to changeset.'''
   736         '''update bugzilla bug with reference to changeset.'''
   724 
   737 
   725         def webroot(root):
   738         def webroot(root):
   726             '''strip leading prefix of repo root and turn into
   739             '''strip leading prefix of repo root and turn into
   727             url-safe path.'''
   740             url-safe path.'''
   750                bug=str(bugid),
   763                bug=str(bugid),
   751                hgweb=self.ui.config('web', 'baseurl'),
   764                hgweb=self.ui.config('web', 'baseurl'),
   752                root=self.repo.root,
   765                root=self.repo.root,
   753                webroot=webroot(self.repo.root))
   766                webroot=webroot(self.repo.root))
   754         data = self.ui.popbuffer()
   767         data = self.ui.popbuffer()
   755         self.add_comment(bugid, data, util.email(ctx.user()))
   768         self.updatebug(bugid, newstate, data, util.email(ctx.user()))
   756 
   769 
   757 def hook(ui, repo, hooktype, node=None, **kwargs):
   770 def hook(ui, repo, hooktype, node=None, **kwargs):
   758     '''add comment to bugzilla for each changeset that refers to a
   771     '''add comment to bugzilla for each changeset that refers to a
   759     bugzilla bug id. only add a comment once per bug, so same change
   772     bugzilla bug id. only add a comment once per bug, so same change
   760     seen multiple times does not fill bug with duplicate data.'''
   773     seen multiple times does not fill bug with duplicate data.'''
   762         raise util.Abort(_('hook type %s does not pass a changeset id') %
   775         raise util.Abort(_('hook type %s does not pass a changeset id') %
   763                          hooktype)
   776                          hooktype)
   764     try:
   777     try:
   765         bz = bugzilla(ui, repo)
   778         bz = bugzilla(ui, repo)
   766         ctx = repo[node]
   779         ctx = repo[node]
   767         ids = bz.find_bug_ids(ctx)
   780         bugs = bz.find_bugs(ctx)
   768         if ids:
   781         if bugs:
   769             for id in ids:
   782             for bug in bugs:
   770                 bz.update(id, ctx)
   783                 bz.update(bug, bugs[bug], ctx)
   771             bz.notify(ids, util.email(ctx.user()))
   784             bz.notify(bugs, util.email(ctx.user()))
   772     except Exception, e:
   785     except Exception, e:
   773         raise util.Abort(_('Bugzilla error: %s') % e)
   786         raise util.Abort(_('Bugzilla error: %s') % e)
   774 
   787