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 |