Edgewall Software

Ticket #7723: commit_ticket_update.2.py

File commit_ticket_update.2.py, 7.7 KB (added by rblank, 3 years ago)

Same script as above, but with original copyright notice.

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