hgext/notify.py
changeset 2203 9569eea1707c
parent 2201 f15056b29472
child 2221 05b6c13f43c6
equal deleted inserted replaced
2202:bc35cd725c37 2203:9569eea1707c
       
     1 # notify.py - email notifications for mercurial
       
     2 #
       
     3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
       
     4 #
       
     5 # This software may be used and distributed according to the terms
       
     6 # of the GNU General Public License, incorporated herein by reference.
       
     7 #
       
     8 # hook extension to email notifications to people when changesets are
       
     9 # committed to a repo they subscribe to.
       
    10 #
       
    11 # default mode is to print messages to stdout, for testing and
       
    12 # configuring.
       
    13 #
       
    14 # to use, configure notify extension and enable in hgrc like this:
       
    15 #
       
    16 #   [extensions]
       
    17 #   hgext.notify =
       
    18 #
       
    19 #   [hooks]
       
    20 #   # one email for each incoming changeset
       
    21 #   incoming.notify = python:hgext.notify.hook
       
    22 #   # batch emails when many changesets incoming at one time
       
    23 #   changegroup.notify = python:hgext.notify.hook
       
    24 #
       
    25 #   [notify]
       
    26 #   # config items go in here
       
    27 #
       
    28 # config items:
       
    29 #
       
    30 # REQUIRED:
       
    31 #   config = /path/to/file # file containing subscriptions
       
    32 #
       
    33 # OPTIONAL:
       
    34 #   test = True            # print messages to stdout for testing
       
    35 #   strip = 3              # number of slashes to strip for url paths
       
    36 #   domain = example.com   # domain to use if committer missing domain
       
    37 #   style = ...            # style file to use when formatting email
       
    38 #   template = ...         # template to use when formatting email
       
    39 #   incoming = ...         # template to use when run as incoming hook
       
    40 #   changegroup = ...      # template when run as changegroup hook
       
    41 #   maxdiff = 300          # max lines of diffs to include (0=none, -1=all)
       
    42 #   maxsubject = 67        # truncate subject line longer than this
       
    43 #   [email]
       
    44 #   from = user@host.com   # email address to send as if none given
       
    45 #   [web]
       
    46 #   baseurl = http://hgserver/... # root of hg web site for browsing commits
       
    47 #
       
    48 # notify config file has same format as regular hgrc. it has two
       
    49 # sections so you can express subscriptions in whatever way is handier
       
    50 # for you.
       
    51 #
       
    52 #   [usersubs]
       
    53 #   # key is subscriber email, value is ","-separated list of glob patterns
       
    54 #   user@host = pattern
       
    55 #
       
    56 #   [reposubs]
       
    57 #   # key is glob pattern, value is ","-separated list of subscriber emails
       
    58 #   pattern = user@host
       
    59 #
       
    60 # glob patterns are matched against path to repo root.
       
    61 #
       
    62 # if you like, you can put notify config file in repo that users can
       
    63 # push changes to, they can manage their own subscriptions.
       
    64 
     1 from mercurial.demandload import *
    65 from mercurial.demandload import *
     2 from mercurial.i18n import gettext as _
    66 from mercurial.i18n import gettext as _
     3 from mercurial.node import *
    67 from mercurial.node import *
     4 demandload(globals(), 'email.MIMEText mercurial:templater,util fnmatch socket')
    68 demandload(globals(), 'email.Parser mercurial:commands,templater,util')
     5 demandload(globals(), 'time')
    69 demandload(globals(), 'fnmatch socket time')
       
    70 
       
    71 # template for single changeset can include email headers.
       
    72 single_template = '''
       
    73 Subject: changeset in {webroot}: {desc|firstline|strip}
       
    74 From: {author}
       
    75 
       
    76 changeset {node|short} in {root}
       
    77 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
       
    78 description:
       
    79 \t{desc|tabindent|strip}
       
    80 '''.lstrip()
       
    81 
       
    82 # template for multiple changesets should not contain email headers,
       
    83 # because only first set of headers will be used and result will look
       
    84 # strange.
       
    85 multiple_template = '''
       
    86 changeset {node|short} in {root}
       
    87 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
       
    88 summary: {desc|firstline}
       
    89 '''
       
    90 
       
    91 deftemplates = {
       
    92     'changegroup': multiple_template,
       
    93     }
     6 
    94 
     7 class notifier(object):
    95 class notifier(object):
     8     def __init__(self, ui, repo):
    96     '''email notification class.'''
       
    97 
       
    98     def __init__(self, ui, repo, hooktype):
     9         self.ui = ui
    99         self.ui = ui
    10         self.ui.readconfig(self.ui.config('notify', 'config'))
   100         self.ui.readconfig(self.ui.config('notify', 'config'))
    11         self.repo = repo
   101         self.repo = repo
    12         self.stripcount = self.ui.config('notify', 'strip')
   102         self.stripcount = int(self.ui.config('notify', 'strip', 0))
    13         self.root = self.strip(self.repo.root)
   103         self.root = self.strip(self.repo.root)
       
   104         self.domain = self.ui.config('notify', 'domain')
       
   105         self.sio = templater.stringio()
       
   106         self.subs = self.subscribers()
       
   107 
       
   108         mapfile = self.ui.config('notify', 'style')
       
   109         template = (self.ui.config('notify', hooktype) or
       
   110                     self.ui.config('notify', 'template'))
       
   111         self.t = templater.changeset_templater(self.ui, self.repo, mapfile,
       
   112                                                self.sio)
       
   113         if not mapfile and not template:
       
   114             template = deftemplates.get(hooktype) or single_template
       
   115         if template:
       
   116             template = templater.parsestring(template, quoted=False)
       
   117             self.t.use_template(template)
    14 
   118 
    15     def strip(self, path):
   119     def strip(self, path):
       
   120         '''strip leading slashes from local path, turn into web-safe path.'''
       
   121 
    16         path = util.pconvert(path)
   122         path = util.pconvert(path)
    17         count = self.stripcount
   123         count = self.stripcount
    18         while path and count >= 0:
   124         while path and count >= 0:
    19             c = path.find('/')
   125             c = path.find('/')
    20             if c == -1:
   126             if c == -1:
    21                 break
   127                 break
    22             path = path[c+1:]
   128             path = path[c+1:]
    23             count -= 1
   129             count -= 1
    24         return path
   130         return path
    25 
   131 
       
   132     def fixmail(self, addr):
       
   133         '''try to clean up email addresses.'''
       
   134 
       
   135         addr = templater.email(addr.strip())
       
   136         a = addr.find('@localhost')
       
   137         if a != -1:
       
   138             addr = addr[:a]
       
   139         if '@' not in addr:
       
   140             return addr + '@' + self.domain
       
   141         return addr
       
   142 
    26     def subscribers(self):
   143     def subscribers(self):
    27         subs = []
   144         '''return list of email addresses of subscribers to this repo.'''
    28         for user, pat in self.ui.configitems('usersubs'):
   145 
    29             if fnmatch.fnmatch(self.root, pat):
   146         subs = {}
    30                 subs.append(user)
   147         for user, pats in self.ui.configitems('usersubs'):
       
   148             for pat in pats.split(','):
       
   149                 if fnmatch.fnmatch(self.repo.root, pat.strip()):
       
   150                     subs[self.fixmail(user)] = 1
    31         for pat, users in self.ui.configitems('reposubs'):
   151         for pat, users in self.ui.configitems('reposubs'):
    32             if fnmatch.fnmatch(self.root, pat):
   152             if fnmatch.fnmatch(self.repo.root, pat):
    33                 subs.extend([u.strip() for u in users.split(',')])
   153                 for user in users.split(','):
       
   154                     subs[self.fixmail(user)] = 1
       
   155         subs = subs.keys()
    34         subs.sort()
   156         subs.sort()
    35         return subs
   157         return subs
    36 
   158 
    37     def seen(self, node):
       
    38         pass
       
    39 
       
    40     def url(self, path=None):
   159     def url(self, path=None):
    41         return self.ui.config('web', 'baseurl') + (path or self.root)
   160         return self.ui.config('web', 'baseurl') + (path or self.root)
    42 
   161 
    43     def message(self, node, changes):
   162     def node(self, node):
    44         sio = templater.stringio()
   163         '''format one changeset.'''
    45         seen = self.seen(node)
   164 
    46         if seen:
   165         self.t.show(changenode=node, changes=self.repo.changelog.read(node),
    47             seen = self.strip(seen)
   166                     baseurl=self.ui.config('web', 'baseurl'),
    48             sio.write('Changeset %s merged to %s\n' %
   167                     root=self.repo.root,
    49                       (short(node), self.url()))
   168                     webroot=self.root)
    50             sio.write('First seen in %s\n' % self.url(seen))
   169 
       
   170     def send(self, node, count):
       
   171         '''send message.'''
       
   172 
       
   173         p = email.Parser.Parser()
       
   174         self.sio.seek(0)
       
   175         msg = p.parse(self.sio)
       
   176 
       
   177         def fix_subject():
       
   178             '''try to make subject line exist and be useful.'''
       
   179 
       
   180             subject = msg['Subject']
       
   181             if not subject:
       
   182                 if count > 1:
       
   183                     subject = _('%s: %d new changesets') % (self.root, count)
       
   184                 else:
       
   185                     changes = self.repo.changelog.read(node)
       
   186                     s = changes[4].lstrip().split('\n', 1)[0].rstrip()
       
   187                     subject = '%s: %s' % (self.root, s)
       
   188             maxsubject = int(self.ui.config('notify', 'maxsubject', 67))
       
   189             if maxsubject and len(subject) > maxsubject:
       
   190                 subject = subject[:maxsubject-3] + '...'
       
   191             del msg['Subject']
       
   192             msg['Subject'] = subject
       
   193 
       
   194         def fix_sender():
       
   195             '''try to make message have proper sender.'''
       
   196 
       
   197             sender = msg['From']
       
   198             if not sender:
       
   199                 sender = self.ui.config('email', 'from') or self.ui.username()
       
   200             if '@' not in sender or '@localhost' in sender:
       
   201                 sender = self.fixmail(sender)
       
   202             del msg['From']
       
   203             msg['From'] = sender
       
   204 
       
   205         fix_subject()
       
   206         fix_sender()
       
   207 
       
   208         msg['X-Hg-Notification'] = 'changeset ' + short(node)
       
   209         if not msg['Message-Id']:
       
   210             msg['Message-Id'] = ('<hg.%s.%s.%s@%s>' %
       
   211                                  (short(node), int(time.time()),
       
   212                                   hash(self.repo.root), socket.getfqdn()))
       
   213 
       
   214         msgtext = msg.as_string(0)
       
   215         if self.ui.configbool('notify', 'test', True):
       
   216             self.ui.write(msgtext)
       
   217             if not msgtext.endswith('\n'):
       
   218                 self.ui.write('\n')
    51         else:
   219         else:
    52             sio.write('Changeset %s new to %s\n' % (short(node), self.url()))
   220             mail = self.ui.sendmail()
    53         sio.write('Committed by %s at %s\n' %
   221             mail.sendmail(templater.email(msg['From']), self.subs, msgtext)
    54                   (changes[1], templater.isodate(changes[2])))
   222 
    55         sio.write('See %s?cmd=changeset;node=%s for full details\n' %
   223     def diff(self, node):
    56                   (self.url(), short(node)))
   224         maxdiff = int(self.ui.config('notify', 'maxdiff', 300))
    57         sio.write('\nDescription:\n')
   225         if maxdiff == 0:
    58         sio.write(templater.indent(changes[4], '  '))
   226             return
    59         msg = email.MIMEText.MIMEText(sio.getvalue(), 'plain')
   227         fp = templater.stringio()
    60         firstline = changes[4].lstrip().split('\n', 1)[0].rstrip()
   228         commands.dodiff(fp, self.ui, self.repo, node,
    61         subject = '%s %s: %s' % (self.root, self.repo.rev(node), firstline)
   229                         self.repo.changelog.tip())
    62         if seen:
   230         difflines = fp.getvalue().splitlines(1)
    63             subject = '[merge] ' + subject
   231         if maxdiff > 0 and len(difflines) > maxdiff:
    64         if subject.endswith('.'):
   232             self.sio.write(_('\ndiffs (truncated from %d to %d lines):\n\n') %
    65             subject = subject[:-1]
   233                            (len(difflines), maxdiff))
    66         if len(subject) > 67:
   234             difflines = difflines[:maxdiff]
    67             subject = subject[:64] + '...'
   235         elif difflines:
    68         msg['Subject'] = subject
   236             self.sio.write(_('\ndiffs (%d lines):\n\n') % len(difflines))
    69         msg['X-Hg-Repo'] = self.root
   237         self.sio.write(*difflines)
    70         if '@' in changes[1]:
       
    71             msg['From'] = changes[1]
       
    72         else:
       
    73             msg['From'] = self.ui.config('email', 'from')
       
    74         msg['Message-Id'] = '<hg.%s.%s.%s@%s>' % (hex(node),
       
    75                                                   int(time.time()),
       
    76                                                   hash(self.repo.root),
       
    77                                                   socket.getfqdn())
       
    78         return msg
       
    79 
       
    80     def node(self, node):
       
    81         mail = self.ui.sendmail()
       
    82         changes = self.repo.changelog.read(node)
       
    83         fromaddr = self.ui.config('email', 'from', changes[1])
       
    84         msg = self.message(node, changes)
       
    85         subs = self.subscribers()
       
    86         msg['To'] = ', '.join(subs)
       
    87         msgtext = msg.as_string(0)
       
    88         mail.sendmail(templater.email(fromaddr),
       
    89                       [templater.email(s) for s in subs],
       
    90                       msgtext)
       
    91 
       
    92 
   238 
    93 def hook(ui, repo, hooktype, node=None, **kwargs):
   239 def hook(ui, repo, hooktype, node=None, **kwargs):
    94     n = notifier(ui, repo)
   240     '''send email notifications to interested subscribers.
    95     n.node(bin(node))
   241 
       
   242     if used as changegroup hook, send one email for all changesets in
       
   243     changegroup. else send one email per changeset.'''
       
   244     n = notifier(ui, repo, hooktype)
       
   245     if not n.subs: return True
       
   246     node = bin(node)
       
   247     if hooktype == 'changegroup':
       
   248         start = repo.changelog.rev(node)
       
   249         end = repo.changelog.count()
       
   250         count = end - start
       
   251         for rev in xrange(start, end):
       
   252             n.node(repo.changelog.node(rev))
       
   253     else:
       
   254         count = 1
       
   255         n.node(node)
       
   256     n.diff(node)
       
   257     n.send(node, count)
       
   258     return True