| 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.ticket import Ticket |
|---|
| 17 |
from trac.ticket.notification import TicketNotifyEmail |
|---|
| 18 |
from trac.ticket.web_ui import TicketModule |
|---|
| 19 |
from trac.util.datefmt import utc |
|---|
| 20 |
|
|---|
| 21 |
# TODO: look only for tickets that match |
|---|
| 22 |
# `projectname:#|(ticket|issue|bug)` |
|---|
| 23 |
# according to configuration |
|---|
| 24 |
# (which also means moving the regex to the class TicketChanger) |
|---|
| 25 |
# move more/all of configuration into the .ini file and therefor editable |
|---|
| 26 |
|
|---|
| 27 |
class TicketChanger(Component): |
|---|
| 28 |
"""annotes and closes tickets on repository commit messages""" |
|---|
| 29 |
|
|---|
| 30 |
implements(IRepositoryHookSubscriber) |
|---|
| 31 |
|
|---|
| 32 |
### options |
|---|
| 33 |
envelope_open = Option('ticket-changer', 'opener', default='', |
|---|
| 34 |
doc='must be present before the action taken to take effect') |
|---|
| 35 |
envelope_close = Option('ticket-changer', 'closer', default='', |
|---|
| 36 |
doc='must be present after the action taken to take effect') |
|---|
| 37 |
intertrac = BoolOption('ticket-changer', 'intertrac', default=False, |
|---|
| 38 |
doc='enforce using ticket prefix from intertrac linking') |
|---|
| 39 |
cmd_close = ListOption('ticket-changer', 'close-commands', |
|---|
| 40 |
default=['close', 'closed', 'closes', 'fix', 'fixed', 'fixes'], |
|---|
| 41 |
doc='commit message tokens that indicate ticket close [e.g. "closes #123"]') |
|---|
| 42 |
cmd_refs = ListOption('ticket-changer', 'references-commands', |
|---|
| 43 |
default=['addresses', 're', 'references', 'refs', 'see'], |
|---|
| 44 |
doc='commit message tokens that indicate ticket reference [e.g. "refs #123"]') |
|---|
| 45 |
|
|---|
| 46 |
def is_available(self, repository, hookname): |
|---|
| 47 |
return True |
|---|
| 48 |
|
|---|
| 49 |
def invoke(self, chgset): |
|---|
| 50 |
|
|---|
| 51 |
# regular expressions |
|---|
| 52 |
ticket_prefix = '(?:#|(?:ticket|issue|bug)[: ]?)' |
|---|
| 53 |
if self.intertrac: # TODO: split to separate function? |
|---|
| 54 |
# find intertrac links |
|---|
| 55 |
intertrac = {} |
|---|
| 56 |
aliases = {} |
|---|
| 57 |
for key, value in self.env.config.options('intertrac'): |
|---|
| 58 |
if '.' in key: |
|---|
| 59 |
name, type_ = key.rsplit('.', 1) |
|---|
| 60 |
if type_ == 'url': |
|---|
| 61 |
intertrac[name] = value |
|---|
| 62 |
else: |
|---|
| 63 |
aliases.setdefault(value, []).append(key) |
|---|
| 64 |
intertrac = dict([(value, [key] + aliases.get(key, [])) for key, value in intertrac.items()]) |
|---|
| 65 |
project = os.path.basename(self.env.path) |
|---|
| 66 |
|
|---|
| 67 |
if '/%s' % project in intertrac: # TODO: checking using base_url for full paths: |
|---|
| 68 |
ticket_prefix = '(?:%s):%s' % ( '|'.join(intertrac['/%s' % project]), |
|---|
| 69 |
ticket_prefix ) |
|---|
| 70 |
else: # hopefully sesible default: |
|---|
| 71 |
ticket_prefix = '%s:%s' % (project, ticket_prefix) |
|---|
| 72 |
|
|---|
| 73 |
ticket_reference = ticket_prefix + '[0-9]+' |
|---|
| 74 |
ticket_command = (r'(?P<action>[A-Za-z]*).?' |
|---|
| 75 |
'(?P<ticket>%s(?:(?:[, &]*|[ ]?and[ ]?)%s)*)' % |
|---|
| 76 |
(ticket_reference, ticket_reference)) |
|---|
| 77 |
ticket_command = r'%s%s%s' % (re.escape(self.envelope_open), |
|---|
| 78 |
ticket_command, |
|---|
| 79 |
re.escape(self.envelope_close)) |
|---|
| 80 |
command_re = re.compile(ticket_command, re.IGNORECASE) |
|---|
| 81 |
ticket_re = re.compile(ticket_prefix + '([0-9]+)', re.IGNORECASE) |
|---|
| 82 |
|
|---|
| 83 |
# other variables |
|---|
| 84 |
msg = "(In [%s]) %s" % (chgset.rev, chgset.message) |
|---|
| 85 |
now = chgset.date |
|---|
| 86 |
supported_cmds = {} # TODO: this could become an extension point |
|---|
| 87 |
supported_cmds.update(dict([(key, self._cmdClose) for key in self.cmd_close])) |
|---|
| 88 |
supported_cmds.update(dict([(key, self._cmdRefs) for key in self.cmd_refs])) |
|---|
| 89 |
|
|---|
| 90 |
cmd_groups = command_re.findall(msg) |
|---|
| 91 |
|
|---|
| 92 |
tickets = {} |
|---|
| 93 |
for cmd, tkts in cmd_groups: |
|---|
| 94 |
func = supported_cmds.get(cmd.lower(), None) |
|---|
| 95 |
if func: |
|---|
| 96 |
for tkt_id in ticket_re.findall(tkts): |
|---|
| 97 |
tickets.setdefault(tkt_id, []).append(func) |
|---|
| 98 |
|
|---|
| 99 |
for tkt_id, cmds in tickets.iteritems(): |
|---|
| 100 |
try: |
|---|
| 101 |
db = self.env.get_db_cnx() |
|---|
| 102 |
|
|---|
| 103 |
ticket = Ticket(self.env, int(tkt_id), db) |
|---|
| 104 |
for cmd in cmds: |
|---|
| 105 |
cmd(ticket) |
|---|
| 106 |
|
|---|
| 107 |
# determine sequence number... |
|---|
| 108 |
cnum = 0 |
|---|
| 109 |
tm = TicketModule(self.env) |
|---|
| 110 |
for change in tm.grouped_changelog_entries(ticket, db): |
|---|
| 111 |
if change['permanent']: |
|---|
| 112 |
cnum += 1 |
|---|
| 113 |
|
|---|
| 114 |
ticket.save_changes(chgset.author, msg, now, db, cnum+1) |
|---|
| 115 |
db.commit() |
|---|
| 116 |
|
|---|
| 117 |
tn = TicketNotifyEmail(self.env) |
|---|
| 118 |
tn.notify(ticket, newticket=0, modtime=now) |
|---|
| 119 |
|
|---|
| 120 |
except Exception, e: |
|---|
| 121 |
# import traceback |
|---|
| 122 |
# traceback.print_exc(file=sys.stderr) |
|---|
| 123 |
print>>sys.stderr, 'Unexpected error while processing ticket ' \ |
|---|
| 124 |
'ID %s: %s' % (tkt_id, e) |
|---|
| 125 |
|
|---|
| 126 |
|
|---|
| 127 |
def _cmdClose(self, ticket): |
|---|
| 128 |
ticket['status'] = 'closed' |
|---|
| 129 |
ticket['resolution'] = 'fixed' |
|---|
| 130 |
|
|---|
| 131 |
def _cmdRefs(self, ticket): |
|---|
| 132 |
pass |
|---|