Edgewall Software

source: trunk/tracopt/ticket/commit_updater.py

Last change on this file was 10968, checked in by cboos, 3 months ago

Merge changes from 0.12-stable (except l10n ones)

  • Property svn:eol-style set to native
File size: 11.8 KB
Line 
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.org/wiki/TracLicense.
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/log/.
13
14# This plugin was based on the contrib/trac-post-commit-hook script, which
15# had the following copyright notice:
16# ----------------------------------------------------------------------------
17# Copyright (c) 2004 Stephen Hansen
18#
19# Permission is hereby granted, free of charge, to any person obtaining a copy
20# of this software and associated documentation files (the "Software"), to
21# deal in the Software without restriction, including without limitation the
22# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
23# sell copies of the Software, and to permit persons to whom the Software is
24# furnished to do so, subject to the following conditions:
25#
26#   The above copyright notice and this permission notice shall be included in
27#   all copies or substantial portions of the Software.
28#
29# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
31# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
32# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
33# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
34# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
35# IN THE SOFTWARE.
36# ----------------------------------------------------------------------------
37
38from __future__ import with_statement
39
40from datetime import datetime
41import re
42
43from genshi.builder import tag
44
45from trac.config import BoolOption, Option
46from trac.core import Component, implements
47from trac.perm import PermissionCache
48from trac.resource import Resource
49from trac.ticket import Ticket
50from trac.ticket.notification import TicketNotifyEmail
51from trac.util.datefmt import utc
52from trac.util.text import exception_to_unicode
53from trac.util.translation import cleandoc_
54from trac.versioncontrol import IRepositoryChangeListener, RepositoryManager
55from trac.versioncontrol.web_ui.changeset import ChangesetModule
56from trac.wiki.formatter import format_to_html
57from trac.wiki.macros import WikiMacroBase
58
59
60class CommitTicketUpdater(Component):
61    """Update tickets based on commit messages.
62   
63    This component hooks into changeset notifications and searches commit
64    messages for text in the form of:
65    {{{
66    command #1
67    command #1, #2
68    command #1 & #2
69    command #1 and #2
70    }}}
71   
72    Instead of the short-hand syntax "#1", "ticket:1" can be used as well,
73    e.g.:
74    {{{
75    command ticket:1
76    command ticket:1, ticket:2
77    command ticket:1 & ticket:2
78    command ticket:1 and ticket:2
79    }}}
80   
81    In addition, the ':' character can be omitted and issue or bug can be used
82    instead of ticket.
83   
84    You can have more than one command in a message. The following commands
85    are supported. There is more than one spelling for each command, to make
86    this as user-friendly as possible.
87   
88      close, closed, closes, fix, fixed, fixes::
89        The specified tickets are closed, and the commit message is added to
90        them as a comment.
91   
92      references, refs, addresses, re, see::
93        The specified tickets are left in their current status, and the commit
94        message is added to them as a comment.
95   
96    A fairly complicated example of what you can do is with a commit message
97    of:
98   
99        Changed blah and foo to do this or that. Fixes #10 and #12,
100        and refs #12.
101   
102    This will close #10 and #12, and add a note to #12.
103    """
104   
105    implements(IRepositoryChangeListener)
106   
107    envelope = Option('ticket', 'commit_ticket_update_envelope', '',
108        """Require commands to be enclosed in an envelope.
109       
110        Must be empty or contain two characters. For example, if set to "[]",
111        then commands must be in the form of [closes #4].""")
112   
113    commands_close = Option('ticket', 'commit_ticket_update_commands.close',
114        'close closed closes fix fixed fixes',
115        """Commands that close tickets, as a space-separated list.""")
116   
117    commands_refs = Option('ticket', 'commit_ticket_update_commands.refs',
118        'addresses re references refs see',
119        """Commands that add a reference, as a space-separated list.
120       
121        If set to the special value <ALL>, all tickets referenced by the
122        message will get a reference to the changeset.""")
123   
124    check_perms = BoolOption('ticket', 'commit_ticket_update_check_perms',
125        'true',
126        """Check that the committer has permission to perform the requested
127        operations on the referenced tickets.
128       
129        This requires that the user names be the same for Trac and repository
130        operations.""")
131
132    notify = BoolOption('ticket', 'commit_ticket_update_notify', 'true',
133        """Send ticket change notification when updating a ticket.""")
134   
135    ticket_prefix = '(?:#|(?:ticket|issue|bug)[: ]?)'
136    ticket_reference = ticket_prefix + '[0-9]+'
137    ticket_command = (r'(?P<action>[A-Za-z]*)\s*.?\s*'
138                      r'(?P<ticket>%s(?:(?:[, &]*|[ ]?and[ ]?)%s)*)' %
139                      (ticket_reference, ticket_reference))
140   
141    @property
142    def command_re(self):
143        (begin, end) = (re.escape(self.envelope[0:1]),
144                        re.escape(self.envelope[1:2]))
145        return re.compile(begin + self.ticket_command + end)
146   
147    ticket_re = re.compile(ticket_prefix + '([0-9]+)')
148   
149    _last_cset_id = None
150   
151    # IRepositoryChangeListener methods
152   
153    def changeset_added(self, repos, changeset):
154        if self._is_duplicate(changeset):
155            return
156        tickets = self._parse_message(changeset.message)
157        comment = self.make_ticket_comment(repos, changeset)
158        self._update_tickets(tickets, changeset, comment,
159                             datetime.now(utc))
160   
161    def changeset_modified(self, repos, changeset, old_changeset):
162        if self._is_duplicate(changeset):
163            return
164        tickets = self._parse_message(changeset.message)
165        old_tickets = {}
166        if old_changeset is not None:
167            old_tickets = self._parse_message(old_changeset.message)
168        tickets = dict(each for each in tickets.iteritems()
169                       if each[0] not in old_tickets)
170        comment = self.make_ticket_comment(repos, changeset)
171        self._update_tickets(tickets, changeset, comment,
172                             datetime.now(utc))
173   
174    def _is_duplicate(self, changeset):
175        # Avoid duplicate changes with multiple scoped repositories
176        cset_id = (changeset.rev, changeset.message, changeset.author,
177                   changeset.date)
178        if cset_id != self._last_cset_id:
179            self._last_cset_id = cset_id
180            return False
181        return True
182       
183    def _parse_message(self, message):
184        """Parse the commit message and return the ticket references."""
185        cmd_groups = self.command_re.findall(message)
186        functions = self._get_functions()
187        tickets = {}
188        for cmd, tkts in cmd_groups:
189            func = functions.get(cmd.lower())
190            if not func and self.commands_refs.strip() == '<ALL>':
191                func = self.cmd_refs
192            if func:
193                for tkt_id in self.ticket_re.findall(tkts):
194                    tickets.setdefault(int(tkt_id), []).append(func)
195        return tickets
196   
197    def make_ticket_comment(self, repos, changeset):
198        """Create the ticket comment from the changeset data."""
199        revstring = str(changeset.rev)
200        if repos.reponame:
201            revstring += '/' + repos.reponame
202        return """\
203In [changeset:%s]:
204{{{
205#!CommitTicketReference repository="%s" revision="%s"
206%s
207}}}""" % (revstring, repos.reponame, changeset.rev, changeset.message.strip())
208       
209    def _update_tickets(self, tickets, changeset, comment, date):
210        """Update the tickets with the given comment."""
211        perm = PermissionCache(self.env, changeset.author)
212        for tkt_id, cmds in tickets.iteritems():
213            try:
214                self.log.debug("Updating ticket #%d", tkt_id)
215                with self.env.db_transaction as db:
216                    ticket = Ticket(self.env, tkt_id, db)
217                    for cmd in cmds:
218                        cmd(ticket, changeset, perm(ticket.resource))
219                    ticket.save_changes(changeset.author, comment, date, db)
220                self._notify(ticket, date)
221            except Exception, e:
222                self.log.error("Unexpected error while processing ticket "
223                               "#%s: %s", tkt_id, exception_to_unicode(e))
224   
225    def _notify(self, ticket, date):
226        """Send a ticket update notification."""
227        if not self.notify:
228            return
229        try:
230            tn = TicketNotifyEmail(self.env)
231            tn.notify(ticket, newticket=False, modtime=date)
232        except Exception, e:
233            self.log.error("Failure sending notification on change to "
234                           "ticket #%s: %s", ticket.id,
235                           exception_to_unicode(e))
236   
237    def _get_functions(self):
238        """Create a mapping from commands to command functions."""
239        functions = {}
240        for each in dir(self):
241            if not each.startswith('cmd_'):
242                continue
243            func = getattr(self, each)
244            for cmd in getattr(self, 'commands_' + each[4:], '').split():
245                functions[cmd] = func
246        return functions
247   
248    def cmd_close(self, ticket, changeset, perm):
249        if not self.check_perms or 'TICKET_MODIFY' in perm:
250            ticket['status'] = 'closed'
251            ticket['resolution'] = 'fixed'
252            if not ticket['owner']:
253                ticket['owner'] = changeset.author
254
255    def cmd_refs(self, ticket, changeset, perm):
256        pass
257
258
259class CommitTicketReferenceMacro(WikiMacroBase):
260    _domain = 'messages'
261    _description = cleandoc_(
262    """Insert a changeset message into the output.
263   
264    This macro must be called using wiki processor syntax as follows:
265    {{{
266    {{{
267    #!CommitTicketReference repository="reponame" revision="rev"
268    }}}
269    }}}
270    where the arguments are the following:
271     - `repository`: the repository containing the changeset
272     - `revision`: the revision of the desired changeset
273    """)
274   
275    def expand_macro(self, formatter, name, content, args={}):
276        reponame = args.get('repository') or ''
277        rev = args.get('revision')
278        repos = RepositoryManager(self.env).get_repository(reponame)
279        try:
280            changeset = repos.get_changeset(rev)
281            message = changeset.message
282            rev = changeset.rev
283            resource = repos.resource
284        except Exception:
285            message = content
286            resource = Resource('repository', reponame)
287        if formatter.context.resource.realm == 'ticket':
288            ticket_re = CommitTicketUpdater.ticket_re
289            if not any(int(tkt_id) == int(formatter.context.resource.id)
290                       for tkt_id in ticket_re.findall(message)):
291                return tag.p("(The changeset message doesn't reference this "
292                             "ticket)", class_='hint')
293        if ChangesetModule(self.env).wiki_format_messages:
294            return tag.div(format_to_html(self.env,
295                formatter.context.child('changeset', rev, parent=resource),
296                message, escape_newlines=True), class_='message')
297        else:
298            return tag.pre(message, class_='message')
Note: See TracBrowser for help on using the repository browser.