Edgewall Software

Ticket #7723: commit_ticket_update.py

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

The functionality of trac-post-commit-hook as a plugin using IRepositoryChangeListener.

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
14import re
15
16from trac.config import *
17from trac.core import Component, implements
18from trac.ticket import Ticket
19from trac.ticket.notification import TicketNotifyEmail
20from trac.ticket.web_ui import TicketModule
21from trac.util.text import exception_to_unicode
22from trac.versioncontrol import IRepositoryChangeListener
23
24
25class 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