1 # notify.py - email notifications for mercurial
3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2, incorporated herein by reference.
8 '''hooks for sending email notifications at commit/push time
10 Subscriptions can be managed through a hgrc file. Default mode is to
11 print messages to stdout, for testing and configuring.
13 To use, configure the notify extension and enable it in hgrc like
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
26 # config items go here
28 Required configuration items::
30 config = /path/to/file # file containing subscriptions
32 Optional configuration items::
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 diffstat = True # add a diffstat before the diff content
44 sources = serve # notify if source of incoming changes in this list
45 # (serve == ssh or http, push, pull, bundle)
47 from = user@host.com # email address to send as if none given
49 baseurl = http://hgserver/... # root of hg web site for browsing commits
51 The notify config file has same format as a regular hgrc file. It has
52 two sections so you can express subscriptions in whatever way is
58 # key is subscriber email, value is ","-separated list of glob patterns
62 # key is glob pattern, value is ","-separated list of subscriber emails
65 Glob patterns are matched against path to repository root.
67 If you like, you can put notify config file in repository that users
68 can push changes to, they can manage their own subscriptions.
71 from mercurial.i18n import _
72 from mercurial import patch, cmdutil, templater, util, mail
73 import email.Parser, email.Errors, fnmatch, socket, time
75 # template for single changeset can include email headers.
77 Subject: changeset in {webroot}: {desc|firstline|strip}
80 changeset {node|short} in {root}
81 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
83 \t{desc|tabindent|strip}
86 # template for multiple changesets should not contain email headers,
87 # because only first set of headers will be used and result will look
89 multiple_template = '''
90 changeset {node|short} in {root}
91 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
92 summary: {desc|firstline}
96 'changegroup': multiple_template,
99 class notifier(object):
100 '''email notification class.'''
102 def __init__(self, ui, repo, hooktype):
104 cfg = self.ui.config('notify', 'config')
106 self.ui.readconfig(cfg, sections=['usersubs', 'reposubs'])
108 self.stripcount = int(self.ui.config('notify', 'strip', 0))
109 self.root = self.strip(self.repo.root)
110 self.domain = self.ui.config('notify', 'domain')
111 self.test = self.ui.configbool('notify', 'test', True)
112 self.charsets = mail._charsets(self.ui)
113 self.subs = self.subscribers()
115 mapfile = self.ui.config('notify', 'style')
116 template = (self.ui.config('notify', hooktype) or
117 self.ui.config('notify', 'template'))
118 self.t = cmdutil.changeset_templater(self.ui, self.repo,
119 False, None, mapfile, False)
120 if not mapfile and not template:
121 template = deftemplates.get(hooktype) or single_template
123 template = templater.parsestring(template, quoted=False)
124 self.t.use_template(template)
126 def strip(self, path):
127 '''strip leading slashes from local path, turn into web-safe path.'''
129 path = util.pconvert(path)
130 count = self.stripcount
139 def fixmail(self, addr):
140 '''try to clean up email addresses.'''
142 addr = util.email(addr.strip())
144 a = addr.find('@localhost')
148 return addr + '@' + self.domain
151 def subscribers(self):
152 '''return list of email addresses of subscribers to this repo.'''
154 for user, pats in self.ui.configitems('usersubs'):
155 for pat in pats.split(','):
156 if fnmatch.fnmatch(self.repo.root, pat.strip()):
157 subs.add(self.fixmail(user))
158 for pat, users in self.ui.configitems('reposubs'):
159 if fnmatch.fnmatch(self.repo.root, pat):
160 for user in users.split(','):
161 subs.add(self.fixmail(user))
162 return [mail.addressencode(self.ui, s, self.charsets, self.test)
163 for s in sorted(subs)]
165 def url(self, path=None):
166 return self.ui.config('web', 'baseurl') + (path or self.root)
169 '''format one changeset.'''
170 self.t.show(ctx, changes=ctx.changeset(),
171 baseurl=self.ui.config('web', 'baseurl'),
172 root=self.repo.root, webroot=self.root)
174 def skipsource(self, source):
175 '''true if incoming changes from this source should be skipped.'''
176 ok_sources = self.ui.config('notify', 'sources', 'serve').split()
177 return source not in ok_sources
179 def send(self, ctx, count, data):
182 p = email.Parser.Parser()
184 msg = p.parsestr(data)
185 except email.Errors.MessageParseError, inst:
186 raise util.Abort(inst)
188 # store sender and subject
189 sender, subject = msg['From'], msg['Subject']
190 del msg['From'], msg['Subject']
192 if not msg.is_multipart():
193 # create fresh mime message from scratch
194 # (multipart templates must take care of this themselves)
195 headers = msg.items()
196 payload = msg.get_payload()
197 # for notification prefer readability over data precision
198 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
199 # reinstate custom headers
203 msg['Date'] = util.datestr(format="%a, %d %b %Y %H:%M:%S %1%2")
205 # try to make subject line exist and be useful
208 subject = _('%s: %d new changesets') % (self.root, count)
210 s = ctx.description().lstrip().split('\n', 1)[0].rstrip()
211 subject = '%s: %s' % (self.root, s)
212 maxsubject = int(self.ui.config('notify', 'maxsubject', 67))
213 if maxsubject and len(subject) > maxsubject:
214 subject = subject[:maxsubject-3] + '...'
215 msg['Subject'] = mail.headencode(self.ui, subject,
216 self.charsets, self.test)
218 # try to make message have proper sender
220 sender = self.ui.config('email', 'from') or self.ui.username()
221 if '@' not in sender or '@localhost' in sender:
222 sender = self.fixmail(sender)
223 msg['From'] = mail.addressencode(self.ui, sender,
224 self.charsets, self.test)
226 msg['X-Hg-Notification'] = 'changeset %s' % ctx
227 if not msg['Message-Id']:
228 msg['Message-Id'] = ('<hg.%s.%s.%s@%s>' %
229 (ctx, int(time.time()),
230 hash(self.repo.root), socket.getfqdn()))
231 msg['To'] = ', '.join(self.subs)
233 msgtext = msg.as_string()
235 self.ui.write(msgtext)
236 if not msgtext.endswith('\n'):
239 self.ui.status(_('notify: sending %d subscribers %d changes\n') %
240 (len(self.subs), count))
241 mail.sendmail(self.ui, util.email(msg['From']),
244 def diff(self, ctx, ref=None):
246 maxdiff = int(self.ui.config('notify', 'maxdiff', 300))
247 prev = ctx.parents()[0].node()
248 ref = ref and ref.node() or ctx.node()
249 chunks = patch.diff(self.repo, prev, ref, opts=patch.diffopts(self.ui))
250 difflines = ''.join(chunks).splitlines()
252 if self.ui.configbool('notify', 'diffstat', True):
253 s = patch.diffstat(difflines)
254 # s may be nil, don't include the header if it is
256 self.ui.write('\ndiffstat:\n\n%s' % s)
260 elif maxdiff > 0 and len(difflines) > maxdiff:
261 msg = _('\ndiffs (truncated from %d to %d lines):\n\n')
262 self.ui.write(msg % (len(difflines), maxdiff))
263 difflines = difflines[:maxdiff]
265 self.ui.write(_('\ndiffs (%d lines):\n\n') % len(difflines))
267 self.ui.write("\n".join(difflines))
269 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
270 '''send email notifications to interested subscribers.
272 if used as changegroup hook, send one email for all changesets in
273 changegroup. else send one email per changeset.'''
275 n = notifier(ui, repo, hooktype)
279 ui.debug(_('notify: no subscribers to repository %s\n') % n.root)
281 if n.skipsource(source):
282 ui.debug(_('notify: changes have source "%s" - skipping\n') % source)
286 if hooktype == 'changegroup':
287 start, end = ctx.rev(), len(repo)
289 for rev in xrange(start, end):
291 n.diff(ctx, repo['tip'])
297 data = ui.popbuffer()
298 n.send(ctx, count, data)