hgext/notify.py
author Martin Geisler <mg@lazybytes.net>
Thu, 09 Jul 2009 22:43:24 +0200
changeset 9105 6188f2cc4a37
parent 9104 c9c5aa12a46e
child 9136 31177742f54a
permissions -rw-r--r--
notify: fix indentation in module docstring

# notify.py - email notifications for mercurial
#
# Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2, incorporated herein by reference.

'''hooks for sending email notifications at commit/push time

Subscriptions can be managed through a hgrc file. Default mode is to print
messages to stdout, for testing and configuring.

To use, configure the notify extension and enable it in hgrc like this:

  [extensions]
  hgext.notify =

  [hooks]
  # one email for each incoming changeset
  incoming.notify = python:hgext.notify.hook
  # batch emails when many changesets incoming at one time
  changegroup.notify = python:hgext.notify.hook

  [notify]
  # config items go here

Required configuration items:

  config = /path/to/file # file containing subscriptions

Optional configuration items:

  test = True            # print messages to stdout for testing
  strip = 3              # number of slashes to strip for url paths
  domain = example.com   # domain to use if committer missing domain
  style = ...            # style file to use when formatting email
  template = ...         # template to use when formatting email
  incoming = ...         # template to use when run as incoming hook
  changegroup = ...      # template when run as changegroup hook
  maxdiff = 300          # max lines of diffs to include (0=none, -1=all)
  maxsubject = 67        # truncate subject line longer than this
  diffstat = True        # add a diffstat before the diff content
  sources = serve        # notify if source of incoming changes in this list
                         # (serve == ssh or http, push, pull, bundle)
  [email]
  from = user@host.com   # email address to send as if none given
  [web]
  baseurl = http://hgserver/... # root of hg web site for browsing commits

The notify config file has same format as a regular hgrc file. It has two
sections so you can express subscriptions in whatever way is handier for you.

  [usersubs]
  # key is subscriber email, value is ","-separated list of glob patterns
  user@host = pattern

  [reposubs]
  # key is glob pattern, value is ","-separated list of subscriber emails
  pattern = user@host

Glob patterns are matched against path to repository root.

If you like, you can put notify config file in repository that users can push
changes to, they can manage their own subscriptions.
'''

from mercurial.i18n import _
from mercurial import patch, cmdutil, templater, util, mail
import email.Parser, fnmatch, socket, time

# template for single changeset can include email headers.
single_template = '''
Subject: changeset in {webroot}: {desc|firstline|strip}
From: {author}

changeset {node|short} in {root}
details: {baseurl}{webroot}?cmd=changeset;node={node|short}
description:
\t{desc|tabindent|strip}
'''.lstrip()

# template for multiple changesets should not contain email headers,
# because only first set of headers will be used and result will look
# strange.
multiple_template = '''
changeset {node|short} in {root}
details: {baseurl}{webroot}?cmd=changeset;node={node|short}
summary: {desc|firstline}
'''

deftemplates = {
    'changegroup': multiple_template,
}

class notifier(object):
    '''email notification class.'''

    def __init__(self, ui, repo, hooktype):
        self.ui = ui
        cfg = self.ui.config('notify', 'config')
        if cfg:
            self.ui.readconfig(cfg, sections=['usersubs', 'reposubs'])
        self.repo = repo
        self.stripcount = int(self.ui.config('notify', 'strip', 0))
        self.root = self.strip(self.repo.root)
        self.domain = self.ui.config('notify', 'domain')
        self.test = self.ui.configbool('notify', 'test', True)
        self.charsets = mail._charsets(self.ui)
        self.subs = self.subscribers()

        mapfile = self.ui.config('notify', 'style')
        template = (self.ui.config('notify', hooktype) or
                    self.ui.config('notify', 'template'))
        self.t = cmdutil.changeset_templater(self.ui, self.repo,
                                             False, None, mapfile, False)
        if not mapfile and not template:
            template = deftemplates.get(hooktype) or single_template
        if template:
            template = templater.parsestring(template, quoted=False)
            self.t.use_template(template)

    def strip(self, path):
        '''strip leading slashes from local path, turn into web-safe path.'''

        path = util.pconvert(path)
        count = self.stripcount
        while count > 0:
            c = path.find('/')
            if c == -1:
                break
            path = path[c+1:]
            count -= 1
        return path

    def fixmail(self, addr):
        '''try to clean up email addresses.'''

        addr = util.email(addr.strip())
        if self.domain:
            a = addr.find('@localhost')
            if a != -1:
                addr = addr[:a]
            if '@' not in addr:
                return addr + '@' + self.domain
        return addr

    def subscribers(self):
        '''return list of email addresses of subscribers to this repo.'''
        subs = set()
        for user, pats in self.ui.configitems('usersubs'):
            for pat in pats.split(','):
                if fnmatch.fnmatch(self.repo.root, pat.strip()):
                    subs.add(self.fixmail(user))
        for pat, users in self.ui.configitems('reposubs'):
            if fnmatch.fnmatch(self.repo.root, pat):
                for user in users.split(','):
                    subs.add(self.fixmail(user))
        return [mail.addressencode(self.ui, s, self.charsets, self.test)
                for s in sorted(subs)]

    def url(self, path=None):
        return self.ui.config('web', 'baseurl') + (path or self.root)

    def node(self, ctx):
        '''format one changeset.'''
        self.t.show(ctx, changes=ctx.changeset(),
                    baseurl=self.ui.config('web', 'baseurl'),
                    root=self.repo.root, webroot=self.root)

    def skipsource(self, source):
        '''true if incoming changes from this source should be skipped.'''
        ok_sources = self.ui.config('notify', 'sources', 'serve').split()
        return source not in ok_sources

    def send(self, ctx, count, data):
        '''send message.'''

        p = email.Parser.Parser()
        msg = p.parsestr(data)

        # store sender and subject
        sender, subject = msg['From'], msg['Subject']
        del msg['From'], msg['Subject']
        # store remaining headers
        headers = msg.items()
        # create fresh mime message from msg body
        text = msg.get_payload()
        # for notification prefer readability over data precision
        msg = mail.mimeencode(self.ui, text, self.charsets, self.test)
        # reinstate custom headers
        for k, v in headers:
            msg[k] = v

        msg['Date'] = util.datestr(format="%a, %d %b %Y %H:%M:%S %1%2")

        # try to make subject line exist and be useful
        if not subject:
            if count > 1:
                subject = _('%s: %d new changesets') % (self.root, count)
            else:
                s = ctx.description().lstrip().split('\n', 1)[0].rstrip()
                subject = '%s: %s' % (self.root, s)
        maxsubject = int(self.ui.config('notify', 'maxsubject', 67))
        if maxsubject and len(subject) > maxsubject:
            subject = subject[:maxsubject-3] + '...'
        msg['Subject'] = mail.headencode(self.ui, subject,
                                         self.charsets, self.test)

        # try to make message have proper sender
        if not sender:
            sender = self.ui.config('email', 'from') or self.ui.username()
        if '@' not in sender or '@localhost' in sender:
            sender = self.fixmail(sender)
        msg['From'] = mail.addressencode(self.ui, sender,
                                         self.charsets, self.test)

        msg['X-Hg-Notification'] = 'changeset %s' % ctx
        if not msg['Message-Id']:
            msg['Message-Id'] = ('<hg.%s.%s.%s@%s>' %
                                 (ctx, int(time.time()),
                                  hash(self.repo.root), socket.getfqdn()))
        msg['To'] = ', '.join(self.subs)

        msgtext = msg.as_string(0)
        if self.test:
            self.ui.write(msgtext)
            if not msgtext.endswith('\n'):
                self.ui.write('\n')
        else:
            self.ui.status(_('notify: sending %d subscribers %d changes\n') %
                           (len(self.subs), count))
            mail.sendmail(self.ui, util.email(msg['From']),
                          self.subs, msgtext)

    def diff(self, ctx, ref=None):

        maxdiff = int(self.ui.config('notify', 'maxdiff', 300))
        prev = ctx.parents()[0].node()
        ref = ref and ref.node() or ctx.node()
        chunks = patch.diff(self.repo, prev, ref, opts=patch.diffopts(self.ui))
        difflines = ''.join(chunks).splitlines()

        if self.ui.configbool('notify', 'diffstat', True):
            s = patch.diffstat(difflines)
            # s may be nil, don't include the header if it is
            if s:
                self.ui.write('\ndiffstat:\n\n%s' % s)

        if maxdiff == 0:
            return
        elif maxdiff > 0 and len(difflines) > maxdiff:
            msg = _('\ndiffs (truncated from %d to %d lines):\n\n')
            self.ui.write(msg % (len(difflines), maxdiff))
            difflines = difflines[:maxdiff]
        elif difflines:
            self.ui.write(_('\ndiffs (%d lines):\n\n') % len(difflines))

        self.ui.write("\n".join(difflines))

def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
    '''send email notifications to interested subscribers.

    if used as changegroup hook, send one email for all changesets in
    changegroup. else send one email per changeset.'''

    n = notifier(ui, repo, hooktype)
    ctx = repo[node]

    if not n.subs:
        ui.debug(_('notify: no subscribers to repository %s\n') % n.root)
        return
    if n.skipsource(source):
        ui.debug(_('notify: changes have source "%s" - skipping\n') % source)
        return

    ui.pushbuffer()
    if hooktype == 'changegroup':
        start, end = ctx.rev(), len(repo)
        count = end - start
        for rev in xrange(start, end):
            n.node(repo[rev])
        n.diff(ctx, repo['tip'])
    else:
        count = 1
        n.node(ctx)
        n.diff(ctx)

    data = ui.popbuffer()
    n.send(ctx, count, data)