]> git.lizzy.rs Git - plan9front.git/blob - sys/lib/python/hgext/notify.py
added factotum support for python and hg
[plan9front.git] / sys / lib / python / hgext / notify.py
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 of the
6 # GNU General Public License version 2, incorporated herein by reference.
7
8 '''hooks for sending email notifications at commit/push time
9
10 Subscriptions can be managed through a hgrc file. Default mode is to
11 print messages to stdout, for testing and configuring.
12
13 To use, configure the notify extension and enable it in hgrc like
14 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 here
27
28 Required configuration items::
29
30   config = /path/to/file # file containing subscriptions
31
32 Optional configuration items::
33
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)
46   [email]
47   from = user@host.com   # email address to send as if none given
48   [web]
49   baseurl = http://hgserver/... # root of hg web site for browsing commits
50
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
53 handier for you.
54
55 ::
56
57   [usersubs]
58   # key is subscriber email, value is ","-separated list of glob patterns
59   user@host = pattern
60
61   [reposubs]
62   # key is glob pattern, value is ","-separated list of subscriber emails
63   pattern = user@host
64
65 Glob patterns are matched against path to repository root.
66
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.
69 '''
70
71 from mercurial.i18n import _
72 from mercurial import patch, cmdutil, templater, util, mail
73 import email.Parser, email.Errors, fnmatch, socket, time
74
75 # template for single changeset can include email headers.
76 single_template = '''
77 Subject: changeset in {webroot}: {desc|firstline|strip}
78 From: {author}
79
80 changeset {node|short} in {root}
81 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
82 description:
83 \t{desc|tabindent|strip}
84 '''.lstrip()
85
86 # template for multiple changesets should not contain email headers,
87 # because only first set of headers will be used and result will look
88 # strange.
89 multiple_template = '''
90 changeset {node|short} in {root}
91 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
92 summary: {desc|firstline}
93 '''
94
95 deftemplates = {
96     'changegroup': multiple_template,
97 }
98
99 class notifier(object):
100     '''email notification class.'''
101
102     def __init__(self, ui, repo, hooktype):
103         self.ui = ui
104         cfg = self.ui.config('notify', 'config')
105         if cfg:
106             self.ui.readconfig(cfg, sections=['usersubs', 'reposubs'])
107         self.repo = repo
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()
114
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
122         if template:
123             template = templater.parsestring(template, quoted=False)
124             self.t.use_template(template)
125
126     def strip(self, path):
127         '''strip leading slashes from local path, turn into web-safe path.'''
128
129         path = util.pconvert(path)
130         count = self.stripcount
131         while count > 0:
132             c = path.find('/')
133             if c == -1:
134                 break
135             path = path[c+1:]
136             count -= 1
137         return path
138
139     def fixmail(self, addr):
140         '''try to clean up email addresses.'''
141
142         addr = util.email(addr.strip())
143         if self.domain:
144             a = addr.find('@localhost')
145             if a != -1:
146                 addr = addr[:a]
147             if '@' not in addr:
148                 return addr + '@' + self.domain
149         return addr
150
151     def subscribers(self):
152         '''return list of email addresses of subscribers to this repo.'''
153         subs = set()
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)]
164
165     def url(self, path=None):
166         return self.ui.config('web', 'baseurl') + (path or self.root)
167
168     def node(self, ctx):
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)
173
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
178
179     def send(self, ctx, count, data):
180         '''send message.'''
181
182         p = email.Parser.Parser()
183         try:
184             msg = p.parsestr(data)
185         except email.Errors.MessageParseError, inst:
186             raise util.Abort(inst)
187
188         # store sender and subject
189         sender, subject = msg['From'], msg['Subject']
190         del msg['From'], msg['Subject']
191
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
200             for k, v in headers:
201                 msg[k] = v
202
203         msg['Date'] = util.datestr(format="%a, %d %b %Y %H:%M:%S %1%2")
204
205         # try to make subject line exist and be useful
206         if not subject:
207             if count > 1:
208                 subject = _('%s: %d new changesets') % (self.root, count)
209             else:
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)
217
218         # try to make message have proper sender
219         if not 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)
225
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)
232
233         msgtext = msg.as_string()
234         if self.test:
235             self.ui.write(msgtext)
236             if not msgtext.endswith('\n'):
237                 self.ui.write('\n')
238         else:
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']),
242                           self.subs, msgtext)
243
244     def diff(self, ctx, ref=None):
245
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()
251
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
255             if s:
256                 self.ui.write('\ndiffstat:\n\n%s' % s)
257
258         if maxdiff == 0:
259             return
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]
264         elif difflines:
265             self.ui.write(_('\ndiffs (%d lines):\n\n') % len(difflines))
266
267         self.ui.write("\n".join(difflines))
268
269 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
270     '''send email notifications to interested subscribers.
271
272     if used as changegroup hook, send one email for all changesets in
273     changegroup. else send one email per changeset.'''
274
275     n = notifier(ui, repo, hooktype)
276     ctx = repo[node]
277
278     if not n.subs:
279         ui.debug(_('notify: no subscribers to repository %s\n') % n.root)
280         return
281     if n.skipsource(source):
282         ui.debug(_('notify: changes have source "%s" - skipping\n') % source)
283         return
284
285     ui.pushbuffer()
286     if hooktype == 'changegroup':
287         start, end = ctx.rev(), len(repo)
288         count = end - start
289         for rev in xrange(start, end):
290             n.node(repo[rev])
291         n.diff(ctx, repo['tip'])
292     else:
293         count = 1
294         n.node(ctx)
295         n.diff(ctx)
296
297     data = ui.popbuffer()
298     n.send(ctx, count, data)