| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (C) 2009 Edgewall Software |
|---|
| 4 | # All rights reserved. |
|---|
| 5 | # |
|---|
| 6 | # This software is licensed as described in the file COPYING, which |
|---|
| 7 | # you should have received as part of this distribution. The terms |
|---|
| 8 | # are also available at http://trac.edgewall.com/license.html. |
|---|
| 9 | # |
|---|
| 10 | # This software consists of voluntary contributions made by many |
|---|
| 11 | # individuals. For the exact contribution history, see the revision |
|---|
| 12 | # history and logs, available at http://trac.edgewall.org/. |
|---|
| 13 | |
|---|
| 14 | import re |
|---|
| 15 | |
|---|
| 16 | from trac.config import * |
|---|
| 17 | from trac.core import Component, implements |
|---|
| 18 | from trac.ticket import Ticket |
|---|
| 19 | from trac.ticket.notification import TicketNotifyEmail |
|---|
| 20 | from trac.ticket.web_ui import TicketModule |
|---|
| 21 | from trac.util.text import exception_to_unicode |
|---|
| 22 | from trac.versioncontrol import IRepositoryChangeListener |
|---|
| 23 | |
|---|
| 24 | |
|---|
| 25 | class CommitTicketUpdate(Component): |
|---|
| 26 | """Update tickets based on commit messages. |
|---|
| 27 | |
|---|
| 28 | This component hooks into changeset notifications and searches commit |
|---|
| 29 | messages for text in the form of: |
|---|
| 30 | command #1 |
|---|
| 31 | command #1, #2 |
|---|
| 32 | command #1 & #2 |
|---|
| 33 | command #1 and #2 |
|---|
| 34 | |
|---|
| 35 | Instead of the short-hand syntax "#1", "ticket:1" can be used as well, |
|---|
| 36 | e.g.: |
|---|
| 37 | command ticket:1 |
|---|
| 38 | command ticket:1, ticket:2 |
|---|
| 39 | command ticket:1 & ticket:2 |
|---|
| 40 | command ticket:1 and ticket:2 |
|---|
| 41 | |
|---|
| 42 | In addition, the ':' character can be omitted and issue or bug can be used |
|---|
| 43 | instead of ticket. |
|---|
| 44 | |
|---|
| 45 | You can have more then one command in a message. The following commands |
|---|
| 46 | are supported. There is more then one spelling for each command, to make |
|---|
| 47 | this as user-friendly as possible. |
|---|
| 48 | |
|---|
| 49 | close, closed, closes, fix, fixed, fixes |
|---|
| 50 | The specified issue numbers are closed with the contents of this |
|---|
| 51 | commit message being added to it. |
|---|
| 52 | references, refs, addresses, re, see |
|---|
| 53 | The specified issue numbers are left in their current status, but |
|---|
| 54 | the contents of this commit message are added to their notes. |
|---|
| 55 | |
|---|
| 56 | A fairly complicated example of what you can do is with a commit message |
|---|
| 57 | of: |
|---|
| 58 | |
|---|
| 59 | Changed blah and foo to do this or that. Fixes #10 and #12, |
|---|
| 60 | and refs #12. |
|---|
| 61 | |
|---|
| 62 | This will close #10 and #12, and add a note to #12. |
|---|
| 63 | """ |
|---|
| 64 | |
|---|
| 65 | implements(IRepositoryChangeListener) |
|---|
| 66 | |
|---|
| 67 | envelope = Option('ticket', 'commit_ref_envelope', '', |
|---|
| 68 | """Require commands to be enclosed in an envelope. |
|---|
| 69 | |
|---|
| 70 | Must be empty or contain two characters. For example, if set to "[]", |
|---|
| 71 | then commands must be in the form of [closes #4]. |
|---|
| 72 | """) |
|---|
| 73 | |
|---|
| 74 | commands_close = Option('ticket', 'commit_ref_commands.close', |
|---|
| 75 | 'close closed closes fix fixed fixes', |
|---|
| 76 | """Commands that close tickets, as a space-separated list.""") |
|---|
| 77 | |
|---|
| 78 | commands_refs = Option('ticket', 'commit_ref_commands.refs', |
|---|
| 79 | 'addresses re references refs see', |
|---|
| 80 | """Commands that add a reference, as a space-separated list.""") |
|---|
| 81 | |
|---|
| 82 | _ticket_prefix = '(?:#|(?:ticket|issue|bug)[: ]?)' |
|---|
| 83 | _ticket_reference = _ticket_prefix + '[0-9]+' |
|---|
| 84 | _ticket_command = (r'(?P<action>[A-Za-z]*).?' |
|---|
| 85 | '(?P<ticket>%s(?:(?:[, &]*|[ ]?and[ ]?)%s)*)' % |
|---|
| 86 | (_ticket_reference, _ticket_reference)) |
|---|
| 87 | |
|---|
| 88 | @property |
|---|
| 89 | def _command_re(self): |
|---|
| 90 | (begin, end) = (re.escape(self.envelope[0:1]), |
|---|
| 91 | re.escape(self.envelope[1:2])) |
|---|
| 92 | return re.compile(begin + self._ticket_command + end) |
|---|
| 93 | |
|---|
| 94 | _ticket_re = re.compile(_ticket_prefix + '([0-9]+)') |
|---|
| 95 | |
|---|
| 96 | _last_cset_id = None |
|---|
| 97 | |
|---|
| 98 | # IRepositoryChangeListener methods |
|---|
| 99 | |
|---|
| 100 | def changeset_added(self, repos, changeset): |
|---|
| 101 | # Avoid duplicate changes with multiple scoped repositories |
|---|
| 102 | cset_id = (changeset.rev, changeset.message, changeset.author, |
|---|
| 103 | changeset.date) |
|---|
| 104 | if cset_id == self._last_cset_id: |
|---|
| 105 | return |
|---|
| 106 | self._last_cset_id = cset_id |
|---|
| 107 | |
|---|
| 108 | revstring = str(changeset.rev) |
|---|
| 109 | if repos.reponame: |
|---|
| 110 | revstring += '/' + repos.reponame |
|---|
| 111 | msg = '(In [%s]) %s' % (revstring, changeset.message) |
|---|
| 112 | |
|---|
| 113 | # Parse message |
|---|
| 114 | cmd_groups = self._command_re.findall(msg) |
|---|
| 115 | functions = self._get_functions() |
|---|
| 116 | tickets = {} |
|---|
| 117 | for cmd, tkts in cmd_groups: |
|---|
| 118 | func = functions.get(cmd.lower()) |
|---|
| 119 | if func: |
|---|
| 120 | for tkt_id in self._ticket_re.findall(tkts): |
|---|
| 121 | tickets.setdefault(tkt_id, []).append(func) |
|---|
| 122 | |
|---|
| 123 | # Update tickets |
|---|
| 124 | db = self.env.get_db_cnx() |
|---|
| 125 | for tkt_id, cmds in tickets.iteritems(): |
|---|
| 126 | try: |
|---|
| 127 | self.log.debug("Updating ticket #%s", tkt_id) |
|---|
| 128 | ticket = Ticket(self.env, int(tkt_id), db) |
|---|
| 129 | for cmd in cmds: |
|---|
| 130 | cmd(ticket) |
|---|
| 131 | |
|---|
| 132 | # Determine sequence number |
|---|
| 133 | cnum = 0 |
|---|
| 134 | tm = TicketModule(self.env) |
|---|
| 135 | for change in tm.grouped_changelog_entries(ticket, db): |
|---|
| 136 | if change['permanent']: |
|---|
| 137 | cnum += 1 |
|---|
| 138 | |
|---|
| 139 | ticket.save_changes(changeset.author, msg, changeset.date, db, |
|---|
| 140 | cnum + 1) |
|---|
| 141 | db.commit() |
|---|
| 142 | |
|---|
| 143 | try: |
|---|
| 144 | tn = TicketNotifyEmail(self.env) |
|---|
| 145 | tn.notify(ticket, newticket=False, modtime=changeset.date) |
|---|
| 146 | except Exception, e: |
|---|
| 147 | self.log.error("Failure sending notification on change to " |
|---|
| 148 | "ticket #%s: %s", ticket.id, |
|---|
| 149 | exception_to_unicode(e)) |
|---|
| 150 | except Exception, e: |
|---|
| 151 | self.log.error("Unexpected error while processing ticket " |
|---|
| 152 | "#%s: %s", ticket.id, exception_to_unicode(e)) |
|---|
| 153 | |
|---|
| 154 | def _get_functions(self): |
|---|
| 155 | """Create a mapping from commands to command functions.""" |
|---|
| 156 | functions = {} |
|---|
| 157 | for each in dir(self): |
|---|
| 158 | if not each.startswith('_cmd_'): |
|---|
| 159 | continue |
|---|
| 160 | func = getattr(self, each) |
|---|
| 161 | for cmd in getattr(self, 'commands_' + each[5:], '').split(): |
|---|
| 162 | functions[cmd] = func |
|---|
| 163 | return functions |
|---|
| 164 | |
|---|
| 165 | def _cmd_close(self, ticket): |
|---|
| 166 | ticket['status'] = 'closed' |
|---|
| 167 | ticket['resolution'] = 'fixed' |
|---|
| 168 | |
|---|
| 169 | def _cmd_refs(self, ticket): |
|---|
| 170 | pass |
|---|
| 171 | |
|---|
| 172 | def changeset_modified(self, repos, changeset): |
|---|
| 173 | pass |
|---|