source: multiprojectcommitticketupdaterplugin/0.12/multicommitupdater/commitupdater.py

Last change on this file was 15722, checked in by Ryan J Ollos, 7 years ago

0.12.2: Fix typos

Also correct indentations differences against original source

File size: 14.8 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Author: Ruth Trevor-Allen 2012, with a hat-tip to my former
4# employers NAG Ltd, Oxford, UK.
5#
6# Do what you like with this code, however, I ask that you respect
7# this one condition:
8#
9# *  The name of the author may not be used to endorse or promote
10#    products derived from this software without specific prior
11#    written permission.
12#
13# THIS SOFTWARE IS PROVIDED BY THE AUTHOR `AS IS'' AND ANY EXPRESS
14# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
16# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
17# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
18# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
19# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
21# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
22# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
23# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24#
25# Based on the file tracopt.ticket.commit_updater.py in Trac 0.12,
26# which was licenced under the following terms:
27#-------------------------------------------------------------------
28# Copyright (C) 2003-2011 Edgewall Software
29# All rights reserved.
30#
31# Redistribution and use in source and binary forms, with or without
32# modification, are permitted provided that the following conditions
33# are met:
34#
35# 1. Redistributions of source code must retain the above copyright
36#    notice, this list of conditions and the following disclaimer.
37# 2. Redistributions in binary form must reproduce the above copyright
38#    notice, this list of conditions and the following disclaimer in
39#    the documentation and/or other materials provided with the
40#    distribution.
41# 3. The name of the author may not be used to endorse or promote
42#    products derived from this software without specific prior
43#    written permission.
44#
45# THIS SOFTWARE IS PROVIDED BY THE AUTHOR `AS IS'' AND ANY EXPRESS
46# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
47# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
48# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
49# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
50# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
51# GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
52# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
53# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
54# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
55# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
56#
57#
58# This software is licensed as described in the file COPYING, which
59# you should have received as part of this distribution. The terms
60# are also available at http://trac.edgewall.org/wiki/TracLicense.
61#
62# This software consists of voluntary contributions made by many
63# individuals. For the exact contribution history, see the revision
64# history and logs, available at http://trac.edgewall.org/log/.
65# The Trac commit_updater plugin was based on the
66# contrib/trac-post-commit-hook script, which had the following copyright
67# notice:
68# ----------------------------------------------------------------------------
69# Copyright (c) 2004 Stephen Hansen
70#
71# Permission is hereby granted, free of charge, to any person obtaining a copy
72# of this software and associated documentation files (the "Software"), to
73# deal in the Software without restriction, including without limitation the
74# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
75# sell copies of the Software, and to permit persons to whom the Software is
76# furnished to do so, subject to the following conditions:
77#
78#   The above copyright notice and this permission notice shall be included in
79#   all copies or substantial portions of the Software.
80#
81# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
82# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
83# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
84# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
85# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
86# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
87# IN THE SOFTWARE.
88# ----------------------------------------------------------------------------
89
90from datetime import datetime
91import re
92
93from genshi.builder import tag
94
95from trac.config import BoolOption, Option
96from trac.core import Component, implements
97from trac.perm import PermissionCache
98from trac.resource import Resource
99from trac.ticket import Ticket
100from trac.ticket.notification import TicketNotifyEmail
101from trac.util.compat import any
102from trac.util.datefmt import utc
103from trac.util.text import exception_to_unicode
104from trac.versioncontrol import IRepositoryChangeListener, RepositoryManager
105from trac.versioncontrol.web_ui.changeset import ChangesetModule
106from trac.wiki.formatter import format_to_html
107from trac.wiki.macros import WikiMacroBase
108
109class MultiProjectCommitTicketUpdater(Component):
110    """Update tickets based on commit messages.
111
112    Extending the functionality of CommitTicketUpdater, this component hooks
113    into changeset notifications and searches commit messages for text in the
114    form of:
115    {{{
116    command my-project:#1
117    command my-project:#1, #2
118    command my-project:#1 & #2
119    command my-project:#1 and #2
120    }}}
121
122    You can have more than one command in a message. The following commands
123    are supported. There is more than one spelling for each command, to make
124    this as user-friendly as possible.
125
126      close, closed, closes, fix, fixed, fixes::
127        The specified tickets are closed, and the commit message is added to
128        them as a comment.
129
130      references, refs, addresses, re, see::
131        The specified tickets are left in their current status, and the commit
132        message is added to them as a comment.
133
134    A fairly complicated example of what you can do is with a commit message
135    of:
136
137        Changed blah and foo to do this or that. Fixes my-project:#10 and
138        #12, and refs my-other-project:#12.
139
140    This will close #10 and #12 in my-project, and add a note to #12 in
141    my-other-project.
142
143    Note that project names must not contain any whitespace characters. Project
144    name matching is case insensitive.
145    """
146
147    implements(IRepositoryChangeListener)
148
149    envelope = Option('multicommitupdater', 'envelope', '',
150        """Require commands to be enclosed in an envelope.
151
152        Must be empty or contain two characters. For example, if set to "[]",
153        then commands must be in the form of [closes my-env#4]. Note that each
154        command must have its own envelope, eg '[closes env1#5], [re env2#3]'""")
155
156    commands_close = Option('multicommitupdater','commands.close',
157        'close closed closes fix fixed fixes',
158        """Commands that close tickets, as a space-separated list.""")
159
160    commands_refs = Option('multicommitupdater','commands.refs',
161        'addresses re references refs see',
162        """Commands that add a reference, as a space-separated list.
163
164        If set to the special value <ALL>, all tickets referenced by the
165        message will get a reference to the changeset.""")
166
167    check_perms = BoolOption('multicommitupdater','check_perms','true',
168        """Check that the committer has permission to perform the requested
169        operations on the referenced tickets.
170
171        This requires that the user names be the same for Trac and repository
172        operations.""")
173
174    notify = BoolOption('multicommitupdater', 'notify','true',
175        """Send ticket change notification when updating a ticket.""")
176
177    project_reference = '[^\s]+:'
178    ticket_prefix = '(?:#|(?:ticket|issue|bug)[: ]?)'
179    ticket_reference = ticket_prefix + '[0-9]+'
180    whole_reference = ' ' + project_reference + ticket_reference
181    ticket_command = (r'(?P<action>[A-Za-z]*)\s*.?\s*'
182                      r'(?P<ticket>%s(?:(?:[, &]*|[ ]?and[ ]?)%s)*)' %
183                      (whole_reference, ticket_reference))
184
185    @property
186    def command_re(self):
187        (begin, end) = (re.escape(self.envelope[0:1]),
188                        re.escape(self.envelope[1:2]))
189        return re.compile(begin + self.ticket_command + end)
190
191    ticket_re = re.compile(ticket_prefix + '([0-9]+)')
192    project_re = re.compile('([^\s]+):')
193
194    _last_cset_id = None
195
196    # IRepositoryChangeListener methods
197
198    def changeset_added(self, repos, changeset):
199        if self._is_duplicate(changeset):
200            return
201        tickets = self._parse_message(changeset.message)
202        comment = self.make_ticket_comment(repos, changeset)
203        self._update_tickets(tickets, changeset, comment,
204                             datetime.now(utc))
205
206    def changeset_modified(self, repos, changeset, old_changeset):
207        if self._is_duplicate(changeset):
208            return
209        tickets = self._parse_message(changeset.message)
210        old_tickets = {}
211        if old_changeset is not None:
212            old_tickets = self._parse_message(old_changeset.message)
213        tickets = dict(each for each in tickets.iteritems()
214                       if each[0] not in old_tickets)
215        comment = self.make_ticket_comment(repos, changeset)
216        self._update_tickets(tickets, changeset, comment,
217                             datetime.now(utc))
218
219    def _is_duplicate(self, changeset):
220        # Avoid duplicate changes with multiple scoped repositories
221        cset_id = (changeset.rev, changeset.message, changeset.author,
222                   changeset.date)
223        if cset_id != self._last_cset_id:
224            self._last_cset_id = cset_id
225            return False
226        return True
227
228    def _parse_message(self, message):
229        """Parse the commit message and return the ticket references."""
230        cmd_groups = self.command_re.findall(message)
231        functions = self._get_functions()
232        tickets = {}
233        for cmd, projects in cmd_groups:
234            project_groups = self.project_re.split(projects)
235            # Deal with blanks - should be one at the start
236            while project_groups.count(' ') > 0:
237                project_groups.remove(' ')
238            # Project name is the first thing in the list, then tickets.
239            name = project_groups.pop(0)
240            if name.lower() == self.env.project_name.lower():
241                func = functions.get(cmd.lower())
242                if not func and self.commands_refs.strip() == '<ALL>':
243                    func = self.cmd_refs
244                if func:
245                    tkts = project_groups.pop(0)
246                    for tkt_id in self.ticket_re.findall(tkts):
247                        tickets.setdefault(int(tkt_id), []).append(func)
248        return tickets
249
250    def make_ticket_comment(self, repos, changeset):
251        """Create the ticket comment from the changeset data."""
252        revstring = str(changeset.rev)
253        if repos.reponame:
254            revstring += '/' + repos.reponame
255        return """\
256In [%s]:
257{{{
258#!CommitTicketReference repository="%s" revision="%s"
259%s
260}}}""" % (revstring, repos.reponame, changeset.rev, changeset.message.strip())
261
262    def _update_tickets(self, tickets, changeset, comment, date):
263        """Update the tickets with the given comment."""
264        perm = PermissionCache(self.env, changeset.author)
265        for tkt_id, cmds in tickets.iteritems():
266            try:
267                self.log.debug("Updating ticket #%d", tkt_id)
268                ticket = [None]
269                @self.env.with_transaction()
270                def do_update(db):
271                    ticket[0] = Ticket(self.env, tkt_id, db)
272                    for cmd in cmds:
273                        cmd(ticket[0], changeset, perm(ticket[0].resource))
274                    ticket[0].save_changes(changeset.author, comment, date, db)
275                self._notify(ticket[0], date)
276            except Exception, e:
277                self.log.error("Unexpected error while processing ticket "
278                               "#%s: %s", tkt_id, exception_to_unicode(e))
279
280    def _notify(self, ticket, date):
281        """Send a ticket update notification."""
282        if not self.notify:
283            return
284        try:
285            tn = TicketNotifyEmail(self.env)
286            tn.notify(ticket, newticket=False, modtime=date)
287        except Exception, e:
288            self.log.error("Failure sending notification on change to "
289                           "ticket #%s: %s", ticket.id,
290                           exception_to_unicode(e))
291
292    def _get_functions(self):
293        """Create a mapping from commands to command functions."""
294        functions = {}
295        for each in dir(self):
296            if not each.startswith('cmd_'):
297                continue
298            func = getattr(self, each)
299            for cmd in getattr(self, 'commands_' + each[4:], '').split():
300                functions[cmd] = func
301        return functions
302
303    def cmd_close(self, ticket, changeset, perm):
304        if not self.check_perms or 'TICKET_MODIFY' in perm:
305            ticket['status'] = 'closed'
306            ticket['resolution'] = 'fixed'
307            if not ticket['owner']:
308                ticket['owner'] = changeset.author
309
310    def cmd_refs(self, ticket, changeset, perm):
311        pass
312
313class CommitTicketReferenceMacro(WikiMacroBase):
314    """Insert a changeset message into the output.
315
316    This macro must be called using wiki processor syntax as follows:
317    {{{
318    {{{
319    #!CommitTicketReference repository="reponame" revision="rev"
320    }}}
321    }}}
322    where the arguments are the following:
323     - `repository`: the repository containing the changeset
324     - `revision`: the revision of the desired changeset
325    """
326
327    def expand_macro(self, formatter, name, content, args={}):
328        reponame = args.get('repository') or ''
329        rev = args.get('revision')
330        repos = RepositoryManager(self.env).get_repository(reponame)
331        try:
332            changeset = repos.get_changeset(rev)
333            message = changeset.message
334            rev = changeset.rev
335            resource = repos.resource
336        except Exception:
337            message = content
338            resource = Resource('repository', reponame)
339        if formatter.context.resource.realm == 'ticket':
340            ticket_re = MultiProjectCommitTicketUpdater.ticket_re
341            if not any(int(tkt_id) == int(formatter.context.resource.id)
342                       for tkt_id in ticket_re.findall(message)):
343                return tag.p("(The changeset message doesn't reference this "
344                             "ticket)", class_='hint')
345        if ChangesetModule(self.env).wiki_format_messages:
346            return tag.div(format_to_html(self.env,
347                formatter.context('changeset', rev, parent=resource),
348                message, escape_newlines=True), class_='message')
349        else:
350            return tag.pre(message, class_='message')
Note: See TracBrowser for help on using the repository browser.