Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- # -*- coding: utf-8 -*-
- #
- # Copyright (C) 2009 Edgewall Software
- # All rights reserved.
- #
- # This software is licensed as described in the file COPYING, which
- # you should have received as part of this distribution. The terms
- # are also available at http://trac.edgewall.org/wiki/TracLicense.
- #
- # This software consists of voluntary contributions made by many
- # individuals. For the exact contribution history, see the revision
- # history and logs, available at http://trac.edgewall.org/log/.
- # This plugin was based on the contrib/trac-post-commit-hook script, which
- # had the following copyright notice:
- # ----------------------------------------------------------------------------
- # Copyright (c) 2004 Stephen Hansen
- #
- # Permission is hereby granted, free of charge, to any person obtaining a copy
- # of this software and associated documentation files (the "Software"), to
- # deal in the Software without restriction, including without limitation the
- # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
- # sell copies of the Software, and to permit persons to whom the Software is
- # furnished to do so, subject to the following conditions:
- #
- # The above copyright notice and this permission notice shall be included in
- # all copies or substantial portions of the Software.
- #
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
- # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
- # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
- # IN THE SOFTWARE.
- # ----------------------------------------------------------------------------
- from __future__ import with_statement
- from datetime import datetime
- import re
- from genshi.builder import tag
- from trac.config import BoolOption, Option
- from trac.core import Component, implements
- from trac.perm import PermissionCache
- from trac.resource import Resource
- from trac.ticket import Ticket
- from trac.ticket.notification import TicketNotifyEmail
- from trac.util.datefmt import utc
- from trac.util.text import exception_to_unicode
- from trac.util.translation import cleandoc_
- from trac.versioncontrol import IRepositoryChangeListener, RepositoryManager
- from trac.versioncontrol.web_ui.changeset import ChangesetModule
- from trac.wiki.formatter import format_to_html
- from trac.wiki.macros import WikiMacroBase
- from multiproduct.env import ProductEnvironment
- class CommitTicketUpdater(Component):
- """Update tickets based on commit messages.
- This component hooks into changeset notifications and searches commit
- messages for text in the form of:
- {{{
- command PREFIX-1
- command PREFIX-1, PREFIX-2
- command PREFIX-1 & PREFIX-2
- command PREFIX-1 and PREFIX-2
- }}}
- Instead of the short-hand syntax "PREFIX-1", "product:PREFIX:ticket:1" and
- PREFIX->ticket:1 can be used as well,
- e.g.:
- {{{
- command product:PREFIX:ticket:1
- command product:PREFIX:ticket:1, product:PREFIX:ticket:2
- command product:PREFIX:ticket:1 & product:PREFIX:ticket:2
- command product:PREFIX:ticket:1 and product:PREFIX:ticket:2
- command PREFIX->ticket:1
- command PREFIX->ticket:1, PREFIX->ticket:2
- command PREFIX->ticket:1 & PREFIX->ticket:2
- command PREFIX->ticket:1 and PREFIX->ticket:2
- }}}
- In addition, issue or bug can be used instead of ticket.
- You can have more than one command in a message. The following commands
- are supported. There is more than one spelling for each command, to make
- this as user-friendly as possible.
- close, closed, closes, fix, fixed, fixes::
- The specified tickets are closed, and the commit message is added to
- them as a comment.
- references, refs, addresses, re, see::
- The specified tickets are left in their current status, and the commit
- message is added to them as a comment.
- A fairly complicated example of what you can do is with a commit message
- of:
- Changed blah and foo to do this or that. Fixes #10 and #12,
- and refs #12.
- This will close #10 and #12, and add a note to #12.
- """
- implements(IRepositoryChangeListener)
- envelope = Option('ticket', 'commit_ticket_update_envelope', '',
- """Require commands to be enclosed in an envelope.
- Must be empty or contain two characters. For example, if set to "[]",
- then commands must be in the form of [closes #4].""")
- commands_close = Option('ticket', 'commit_ticket_update_commands.close',
- 'close closed closes fix fixed fixes',
- """Commands that close tickets, as a space-separated list.""")
- commands_refs = Option('ticket', 'commit_ticket_update_commands.refs',
- 'addresses re references refs see',
- """Commands that add a reference, as a space-separated list.
- If set to the special value <ALL>, all tickets referenced by the
- message will get a reference to the changeset.""")
- check_perms = BoolOption('ticket', 'commit_ticket_update_check_perms',
- 'true',
- """Check that the committer has permission to perform the requested
- operations on the referenced tickets.
- This requires that the user names be the same for Trac and repository
- operations.""")
- notify = BoolOption('ticket', 'commit_ticket_update_notify', 'true',
- """Send ticket change notification when updating a ticket.""")
- ticket_prefix = r'(?:ticket|issue|bug):'
- product_prefix = r'\w+' # FIXME : Relax alphanumeric prefix constraint ?
- # Note: _ marks locations where group might be needed (depends on context)
- local_ticket_ref = ticket_prefix + r'(_[0-9]+)'
- jira_ticket_ref = r'(_%s)-(_[0-9]+)' % (product_prefix,)
- short_ticket_ref = r'(_%s)->%s' % (product_prefix, local_ticket_ref)
- long_ticket_ref = r'product:(_%s):%s|' \
- 'product:"(_[^:"]+):%s"' % (product_prefix,
- local_ticket_ref,
- local_ticket_ref)
- ticket_reference = r'(?:%s)|(?:%s)|(?:%s)' % (jira_ticket_ref.replace('_',
- '?:'),
- short_ticket_ref.replace('_',
- '?:'),
- long_ticket_ref.replace('_',
- '?:'))
- ticket_command = (r'(?P<action>[A-Za-z]+)\s*'
- r'(?P<ticket>(?:%s)(?:(?:[, &]*|[ ]?and[ ]?)(?:%s))*)' %
- (ticket_reference, ticket_reference))
- @property
- def command_re(self):
- (begin, end) = (re.escape(self.envelope[0:1]),
- re.escape(self.envelope[1:2]))
- return re.compile(begin + self.ticket_command + end)
- ticket_re = r'(?:%s)|(?:%s)|(?:%s)' % (jira_ticket_ref.replace('_', ''),
- short_ticket_ref.replace('_', ''),
- long_ticket_ref.replace('_', ''))
- ticket_re = re.compile(ticket_re)
- _last_cset_id = None
- # IRepositoryChangeListener methods
- def changeset_added(self, repos, changeset):
- self.log.debug('CommitTicketUpdater : Added %s in %s',
- changeset.rev, repos.name)
- if self._is_duplicate(changeset):
- return
- tickets = self._parse_message(changeset.message)
- self.log.debug('CommitTicketUpdater : %d tickets adding %s in %s',
- len(tickets), changeset.rev, repos.name)
- comment = self.make_ticket_comment(repos, changeset)
- self.log.debug('CommitTicketUpdater : Processing %d tickets : %s',
- len(tickets), tickets.keys())
- self._update_tickets(tickets, changeset, comment,
- datetime.now(utc))
- self.log.debug('CommitTicketUpdater : Added %s in %s ... ok',
- changeset.rev, repos.name)
- def changeset_modified(self, repos, changeset, old_changeset):
- self.log.debug('CommitTicketUpdater : Modified %s in %s',
- changeset.rev, repos.name)
- if self._is_duplicate(changeset):
- return
- tickets = self._parse_message(changeset.message)
- old_tickets = {}
- if old_changeset is not None:
- old_tickets = self._parse_message(old_changeset.message)
- tickets = dict(each for each in tickets.iteritems()
- if each[0] not in old_tickets)
- self.log.debug('CommitTicketUpdater : %d tickets modifying %s in %s',
- len(tickets), len(tickets), changeset.rev, repos.name)
- comment = self.make_ticket_comment(repos, changeset)
- self._update_tickets(tickets, changeset, comment,
- datetime.now(utc))
- self.log.debug('CommitTicketUpdater : Modified %s in %s ... ok',
- changeset.rev, repos.name)
- def _is_duplicate(self, changeset):
- # Avoid duplicate changes with multiple scoped repositories
- cset_id = (changeset.rev, changeset.message, changeset.author,
- changeset.date)
- if cset_id != self._last_cset_id:
- self._last_cset_id = cset_id
- return False
- return True
- def _parse_message(self, message):
- """Parse the commit message and return the ticket references."""
- cmd_groups = self.command_re.findall(message)
- functions = self._get_functions()
- tickets = {}
- for cmd, tkts in cmd_groups:
- cmd = cmd.lower()
- func = functions.get(cmd)
- if not func and self.commands_refs.strip() == '<ALL>':
- func = self.cmd_refs
- if func:
- _tkts = (filter(None, match)
- for match in self.ticket_re.findall(tkts))
- for pid, tkt_id in _tkts:
- tickets.setdefault((pid, int(tkt_id)), {})[func.__name__] = func
- return dict([k, v.values()] for k,v in tickets.iteritems())
- def make_ticket_comment(self, repos, changeset):
- """Create the ticket comment from the changeset data."""
- revstring = str(changeset.rev)
- if repos.reponame:
- revstring += '/' + repos.reponame
- return """\
- In [changeset:"%s"]:
- {{{
- #!CommitTicketReference repository="%s" revision="%s"
- %s
- }}}""" % (revstring, repos.reponame, changeset.rev, changeset.message.strip())
- def _update_tickets(self, tickets, changeset, comment, date):
- """Update the tickets with the given comment."""
- perms_pool = {}
- for (pid, tkt_id), cmds in tickets.iteritems():
- try:
- save = False
- try:
- env = ProductEnvironment(self.env, pid)
- except LookupError:
- self.env.log.warning("Changeset %s: skip #%d in unknown "
- "product '%s'.", changeset.rev,
- tkt_id, pid)
- continue
- env.log.debug("Updating ticket #%d (%s)", tkt_id, pid)
- author = changeset.author
- with env.db_transaction:
- ticket = Ticket(env, tkt_id)
- if pid not in perms_pool:
- perms_pool[pid] = perm = PermissionCache(env, author)
- else:
- perm = perms_pool[pid]
- ticket_perm = perm(ticket.resource)
- for cmd in cmds:
- if cmd(ticket, changeset, ticket_perm) is not False:
- save = True
- if save:
- ticket.save_changes(author, comment, date)
- if save:
- self._notify(ticket, date)
- except Exception, e:
- self.log.error("Unexpected error while processing ticket "
- "#%s: %s", tkt_id, exception_to_unicode(e))
- def _notify(self, ticket, date):
- """Send a ticket update notification."""
- do_notify = self.notify if ticket.env is self.env \
- else CommitTicketUpdater(ticket.env).notify
- if not do_notify:
- return
- try:
- tn = TicketNotifyEmail(ticket.env)
- tn.notify(ticket, newticket=False, modtime=date)
- except Exception, e:
- ticket.env.log.error("Failure sending notification on change to "
- "ticket #%s: %s", ticket.id,
- exception_to_unicode(e))
- def _get_functions(self):
- """Create a mapping from commands to command functions."""
- functions = {}
- for each in dir(self):
- if not each.startswith('cmd_'):
- continue
- func = getattr(self, each)
- for cmd in getattr(self, 'commands_' + each[4:], '').split():
- functions[cmd] = func
- return functions
- # Command-specific behavior
- # The ticket isn't updated if all extracted commands return False.
- def cmd_close(self, ticket, changeset, perm):
- if self.check_perms and not 'TICKET_MODIFY' in perm:
- self.log.info("%s doesn't have TICKET_MODIFY permission for #%d",
- changeset.author, ticket.id)
- return False
- ticket['status'] = 'closed'
- ticket['resolution'] = 'fixed'
- if not ticket['owner']:
- ticket['owner'] = changeset.author
- def cmd_refs(self, ticket, changeset, perm):
- if self.check_perms and not 'TICKET_APPEND' in perm:
- self.log.info("%s doesn't have TICKET_APPEND permission for #%d",
- changeset.author, ticket.id)
- return False
- class CommitTicketReferenceMacro(WikiMacroBase):
- _domain = 'messages'
- _description = cleandoc_(
- """Insert a changeset message into the output.
- This macro must be called using wiki processor syntax as follows:
- {{{
- {{{
- #!CommitTicketReference repository="reponame" revision="rev"
- }}}
- }}}
- where the arguments are the following:
- - `repository`: the repository containing the changeset
- - `revision`: the revision of the desired changeset
- """)
- def expand_macro(self, formatter, name, content, args={}):
- reponame = args.get('repository') or ''
- rev = args.get('revision')
- repos = RepositoryManager(self.env).get_repository(reponame)
- try:
- changeset = repos.get_changeset(rev)
- message = changeset.message
- rev = changeset.rev
- resource = repos.resource
- except Exception:
- message = content
- resource = Resource('repository', reponame)
- if formatter.context.resource.realm == 'ticket':
- try:
- product = self.env.product
- except AttributeError:
- # Skip silently on global environment
- pass
- else:
- # ... and enforce constraint in product context
- cur_tkt = (product.prefix, int(formatter.context.resource.id))
- ticket_re = CommitTicketUpdater.ticket_re
- _tkts = (filter(None, match)
- for match in ticket_re.findall(message))
- if not any((pid, int(tkt_id)) == cur_tkt
- for pid, tkt_id in _tkts):
- return tag.p("(The changeset message doesn't reference this "
- "ticket)", class_='hint')
- if ChangesetModule(self.env).wiki_format_messages:
- return tag.div(format_to_html(self.env,
- formatter.context.child('changeset', rev, parent=resource),
- message, escape_newlines=True), class_='message')
- else:
- return tag.pre(message, class_='message')
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement