| 1 | """ |
|---|
| 2 | annotes and closes tickets based on an SVN commit message; |
|---|
| 3 | port of http://trac.edgewall.org/browser/trunk/contrib/trac-post-commit-hook |
|---|
| 4 | """ |
|---|
| 5 | |
|---|
| 6 | import os |
|---|
| 7 | import re |
|---|
| 8 | import sys |
|---|
| 9 | |
|---|
| 10 | from datetime import datetime |
|---|
| 11 | from repository_hook_system.interface import IRepositoryHookSubscriber |
|---|
| 12 | from trac.config import BoolOption |
|---|
| 13 | from trac.config import ListOption |
|---|
| 14 | from trac.config import Option |
|---|
| 15 | from trac.core import * |
|---|
| 16 | from trac.perm import PermissionCache |
|---|
| 17 | from trac.ticket import Ticket |
|---|
| 18 | from trac.ticket.notification import TicketNotifyEmail |
|---|
| 19 | from trac.ticket.web_ui import TicketModule |
|---|
| 20 | from trac.util.datefmt import utc |
|---|
| 21 | |
|---|
| 22 | from trac.web.api import Request # XXX needed for the TicketManipulators |
|---|
| 23 | |
|---|
| 24 | from StringIO import StringIO |
|---|
| 25 | |
|---|
| 26 | class TicketChanger(Component): |
|---|
| 27 | """annotes and closes tickets on repository commit messages""" |
|---|
| 28 | |
|---|
| 29 | implements(IRepositoryHookSubscriber) |
|---|
| 30 | |
|---|
| 31 | ### options |
|---|
| 32 | envelope_open = Option('ticket-changer', 'opener', default='', |
|---|
| 33 | doc='must be present before the action taken to take effect') |
|---|
| 34 | envelope_close = Option('ticket-changer', 'closer', default='', |
|---|
| 35 | doc='must be present after the action taken to take effect') |
|---|
| 36 | intertrac = BoolOption('ticket-changer', 'intertrac', default=False, |
|---|
| 37 | doc='enforce using ticket prefix from intertrac linking') |
|---|
| 38 | cmd_close = ListOption('ticket-changer', 'close-commands', |
|---|
| 39 | default='close, closed, closes, fix, fixed, fixes', |
|---|
| 40 | doc='commit message tokens that indicate ticket close [e.g. "closes #123"]') |
|---|
| 41 | cmd_refs = ListOption('ticket-changer', 'references-commands', |
|---|
| 42 | default='addresses, re, references, refs, see', |
|---|
| 43 | doc='commit message tokens that indicate ticket reference [e.g. "refs #123"]') |
|---|
| 44 | |
|---|
| 45 | def is_available(self, repository, hookname): |
|---|
| 46 | return True |
|---|
| 47 | |
|---|
| 48 | def invoke(self, chgset): |
|---|
| 49 | |
|---|
| 50 | # regular expressions |
|---|
| 51 | ticket_prefix = '(?:#|(?:ticket|issue|bug)[: ]?)' |
|---|
| 52 | if self.intertrac: # TODO: split to separate function? |
|---|
| 53 | # find intertrac links |
|---|
| 54 | intertrac = {} |
|---|
| 55 | aliases = {} |
|---|
| 56 | for key, value in self.env.config.options('intertrac'): |
|---|
| 57 | if '.' in key: |
|---|
| 58 | name, type_ = key.rsplit('.', 1) |
|---|
| 59 | if type_ == 'url': |
|---|
| 60 | intertrac[name] = value |
|---|
| 61 | else: |
|---|
| 62 | aliases.setdefault(value, []).append(key) |
|---|
| 63 | intertrac = dict([(value, [key] + aliases.get(key, [])) for key, value in intertrac.items()]) |
|---|
| 64 | project = os.path.basename(self.env.path) |
|---|
| 65 | |
|---|
| 66 | if '/%s' % project in intertrac: # TODO: checking using base_url for full paths: |
|---|
| 67 | ticket_prefix = '(?:%s):%s' % ( '|'.join(intertrac['/%s' % project]), |
|---|
| 68 | ticket_prefix ) |
|---|
| 69 | else: # hopefully sesible default: |
|---|
| 70 | ticket_prefix = '%s:%s' % (project, ticket_prefix) |
|---|
| 71 | |
|---|
| 72 | ticket_reference = ticket_prefix + '[0-9]+' |
|---|
| 73 | ticket_command = (r'(?P<action>[A-Za-z]*).?' |
|---|
| 74 | '(?P<ticket>%s(?:(?:[, &]*|[ ]?and[ ]?)%s)*)' % |
|---|
| 75 | (ticket_reference, ticket_reference)) |
|---|
| 76 | ticket_command = r'%s%s%s' % (re.escape(self.envelope_open), |
|---|
| 77 | ticket_command, |
|---|
| 78 | re.escape(self.envelope_close)) |
|---|
| 79 | command_re = re.compile(ticket_command, re.IGNORECASE) |
|---|
| 80 | ticket_re = re.compile(ticket_prefix + '([0-9]+)', re.IGNORECASE) |
|---|
| 81 | |
|---|
| 82 | # other variables |
|---|
| 83 | msg = "(In [%s]) %s" % (chgset.rev, chgset.message) |
|---|
| 84 | now = chgset.date |
|---|
| 85 | supported_cmds = {} # TODO: this could become an extension point |
|---|
| 86 | supported_cmds.update(dict([(key, self._cmdClose) for key in self.cmd_close])) |
|---|
| 87 | supported_cmds.update(dict([(key, self._cmdRefs) for key in self.cmd_refs])) |
|---|
| 88 | |
|---|
| 89 | cmd_groups = command_re.findall(msg) |
|---|
| 90 | |
|---|
| 91 | tickets = {} |
|---|
| 92 | for cmd, tkts in cmd_groups: |
|---|
| 93 | func = supported_cmds.get(cmd.lower(), None) |
|---|
| 94 | if func: |
|---|
| 95 | for tkt_id in ticket_re.findall(tkts): |
|---|
| 96 | tickets.setdefault(tkt_id, []).append(func) |
|---|
| 97 | |
|---|
| 98 | for tkt_id, cmds in tickets.iteritems(): |
|---|
| 99 | try: |
|---|
| 100 | db = self.env.get_db_cnx() |
|---|
| 101 | |
|---|
| 102 | ticket = Ticket(self.env, int(tkt_id), db) |
|---|
| 103 | for cmd in cmds: |
|---|
| 104 | cmd(ticket) |
|---|
| 105 | |
|---|
| 106 | # determine comment sequence number |
|---|
| 107 | cnum = 0 |
|---|
| 108 | tm = TicketModule(self.env) |
|---|
| 109 | for change in tm.grouped_changelog_entries(ticket, db): |
|---|
| 110 | if change['permanent']: |
|---|
| 111 | cnum += 1 |
|---|
| 112 | |
|---|
| 113 | # validate the ticket |
|---|
| 114 | |
|---|
| 115 | # fake a request |
|---|
| 116 | # XXX cargo-culted environ from |
|---|
| 117 | # http://trac.edgewall.org/browser/trunk/trac/web/tests/api.py |
|---|
| 118 | environ = { 'wsgi.url_scheme': 'http', |
|---|
| 119 | 'wsgi.input': StringIO(''), |
|---|
| 120 | 'SERVER_NAME': '0.0.0.0', |
|---|
| 121 | 'REQUEST_METHOD': 'POST', |
|---|
| 122 | 'SERVER_PORT': 80, |
|---|
| 123 | 'SCRIPT_NAME': '/' + self.env.project_name, |
|---|
| 124 | 'REMOTE_USER': chgset.author, |
|---|
| 125 | 'QUERY_STRING': '' |
|---|
| 126 | } |
|---|
| 127 | req = Request(environ, None) |
|---|
| 128 | req.args['comment'] = msg |
|---|
| 129 | req.authname = chgset.author |
|---|
| 130 | req.perm = PermissionCache(self.env, req.authname) |
|---|
| 131 | for manipulator in tm.ticket_manipulators: |
|---|
| 132 | manipulator.validate_ticket(req, ticket) |
|---|
| 133 | msg = req.args['comment'] |
|---|
| 134 | ticket.save_changes(chgset.author, msg, now, db, cnum+1) |
|---|
| 135 | db.commit() |
|---|
| 136 | |
|---|
| 137 | tn = TicketNotifyEmail(self.env) |
|---|
| 138 | tn.notify(ticket, newticket=0, modtime=now) |
|---|
| 139 | |
|---|
| 140 | except Exception, e: |
|---|
| 141 | message = 'Unexpected error while processing ticket ID %s: %s' % (tkt_id, repr(e)) |
|---|
| 142 | print>>sys.stderr, message |
|---|
| 143 | self.env.log.error('TicketChanger: ' + message) |
|---|
| 144 | |
|---|
| 145 | |
|---|
| 146 | def _cmdClose(self, ticket): |
|---|
| 147 | ticket['status'] = 'closed' |
|---|
| 148 | ticket['resolution'] = 'fixed' |
|---|
| 149 | |
|---|
| 150 | def _cmdRefs(self, ticket): |
|---|
| 151 | pass |
|---|