##// END OF EJS Templates
notify: permit suppression of merge changeset notification...
David Champion -
r9516:f8048c33 default
parent child Browse files
Show More
@@ -1,298 +1,316 b''
1 # notify.py - email notifications for mercurial
1 # notify.py - email notifications for mercurial
2 #
2 #
3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
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.
6 # GNU General Public License version 2, incorporated herein by reference.
7
7
8 '''hooks for sending email notifications at commit/push time
8 '''hooks for sending email notifications at commit/push time
9
9
10 Subscriptions can be managed through a hgrc file. Default mode is to
10 Subscriptions can be managed through a hgrc file. Default mode is to
11 print messages to stdout, for testing and configuring.
11 print messages to stdout, for testing and configuring.
12
12
13 To use, configure the notify extension and enable it in hgrc like
13 To use, configure the notify extension and enable it in hgrc like
14 this::
14 this::
15
15
16 [extensions]
16 [extensions]
17 hgext.notify =
17 hgext.notify =
18
18
19 [hooks]
19 [hooks]
20 # one email for each incoming changeset
20 # one email for each incoming changeset
21 incoming.notify = python:hgext.notify.hook
21 incoming.notify = python:hgext.notify.hook
22 # batch emails when many changesets incoming at one time
22 # batch emails when many changesets incoming at one time
23 changegroup.notify = python:hgext.notify.hook
23 changegroup.notify = python:hgext.notify.hook
24
24
25 [notify]
25 [notify]
26 # config items go here
26 # config items go here
27
27
28 Required configuration items::
28 Required configuration items::
29
29
30 config = /path/to/file # file containing subscriptions
30 config = /path/to/file # file containing subscriptions
31
31
32 Optional configuration items::
32 Optional configuration items::
33
33
34 test = True # print messages to stdout for testing
34 test = True # print messages to stdout for testing
35 strip = 3 # number of slashes to strip for url paths
35 strip = 3 # number of slashes to strip for url paths
36 domain = example.com # domain to use if committer missing domain
36 domain = example.com # domain to use if committer missing domain
37 style = ... # style file to use when formatting email
37 style = ... # style file to use when formatting email
38 template = ... # template to use when formatting email
38 template = ... # template to use when formatting email
39 incoming = ... # template to use when run as incoming hook
39 incoming = ... # template to use when run as incoming hook
40 changegroup = ... # template when run as changegroup hook
40 changegroup = ... # template when run as changegroup hook
41 maxdiff = 300 # max lines of diffs to include (0=none, -1=all)
41 maxdiff = 300 # max lines of diffs to include (0=none, -1=all)
42 maxsubject = 67 # truncate subject line longer than this
42 maxsubject = 67 # truncate subject line longer than this
43 diffstat = True # add a diffstat before the diff content
43 diffstat = True # add a diffstat before the diff content
44 sources = serve # notify if source of incoming changes in this list
44 sources = serve # notify if source of incoming changes in this list
45 # (serve == ssh or http, push, pull, bundle)
45 # (serve == ssh or http, push, pull, bundle)
46 merge = False # send notification for merges (default True)
46 [email]
47 [email]
47 from = user@host.com # email address to send as if none given
48 from = user@host.com # email address to send as if none given
48 [web]
49 [web]
49 baseurl = http://hgserver/... # root of hg web site for browsing commits
50 baseurl = http://hgserver/... # root of hg web site for browsing commits
50
51
51 The notify config file has same format as a regular hgrc file. It has
52 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 two sections so you can express subscriptions in whatever way is
53 handier for you.
54 handier for you.
54
55
55 ::
56 ::
56
57
57 [usersubs]
58 [usersubs]
58 # key is subscriber email, value is ","-separated list of glob patterns
59 # key is subscriber email, value is ","-separated list of glob patterns
59 user@host = pattern
60 user@host = pattern
60
61
61 [reposubs]
62 [reposubs]
62 # key is glob pattern, value is ","-separated list of subscriber emails
63 # key is glob pattern, value is ","-separated list of subscriber emails
63 pattern = user@host
64 pattern = user@host
64
65
65 Glob patterns are matched against path to repository root.
66 Glob patterns are matched against path to repository root.
66
67
67 If you like, you can put notify config file in repository that users
68 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 can push changes to, they can manage their own subscriptions.
69 '''
70 '''
70
71
71 from mercurial.i18n import _
72 from mercurial.i18n import _
72 from mercurial import patch, cmdutil, templater, util, mail
73 from mercurial import patch, cmdutil, templater, util, mail
73 import email.Parser, email.Errors, fnmatch, socket, time
74 import email.Parser, email.Errors, fnmatch, socket, time
74
75
75 # template for single changeset can include email headers.
76 # template for single changeset can include email headers.
76 single_template = '''
77 single_template = '''
77 Subject: changeset in {webroot}: {desc|firstline|strip}
78 Subject: changeset in {webroot}: {desc|firstline|strip}
78 From: {author}
79 From: {author}
79
80
80 changeset {node|short} in {root}
81 changeset {node|short} in {root}
81 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
82 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
82 description:
83 description:
83 \t{desc|tabindent|strip}
84 \t{desc|tabindent|strip}
84 '''.lstrip()
85 '''.lstrip()
85
86
86 # template for multiple changesets should not contain email headers,
87 # template for multiple changesets should not contain email headers,
87 # because only first set of headers will be used and result will look
88 # because only first set of headers will be used and result will look
88 # strange.
89 # strange.
89 multiple_template = '''
90 multiple_template = '''
90 changeset {node|short} in {root}
91 changeset {node|short} in {root}
91 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
92 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
92 summary: {desc|firstline}
93 summary: {desc|firstline}
93 '''
94 '''
94
95
95 deftemplates = {
96 deftemplates = {
96 'changegroup': multiple_template,
97 'changegroup': multiple_template,
97 }
98 }
98
99
99 class notifier(object):
100 class notifier(object):
100 '''email notification class.'''
101 '''email notification class.'''
101
102
102 def __init__(self, ui, repo, hooktype):
103 def __init__(self, ui, repo, hooktype):
103 self.ui = ui
104 self.ui = ui
104 cfg = self.ui.config('notify', 'config')
105 cfg = self.ui.config('notify', 'config')
105 if cfg:
106 if cfg:
106 self.ui.readconfig(cfg, sections=['usersubs', 'reposubs'])
107 self.ui.readconfig(cfg, sections=['usersubs', 'reposubs'])
107 self.repo = repo
108 self.repo = repo
108 self.stripcount = int(self.ui.config('notify', 'strip', 0))
109 self.stripcount = int(self.ui.config('notify', 'strip', 0))
109 self.root = self.strip(self.repo.root)
110 self.root = self.strip(self.repo.root)
110 self.domain = self.ui.config('notify', 'domain')
111 self.domain = self.ui.config('notify', 'domain')
111 self.test = self.ui.configbool('notify', 'test', True)
112 self.test = self.ui.configbool('notify', 'test', True)
112 self.charsets = mail._charsets(self.ui)
113 self.charsets = mail._charsets(self.ui)
113 self.subs = self.subscribers()
114 self.subs = self.subscribers()
115 self.merge = self.ui.configbool('notify', 'merge', True)
114
116
115 mapfile = self.ui.config('notify', 'style')
117 mapfile = self.ui.config('notify', 'style')
116 template = (self.ui.config('notify', hooktype) or
118 template = (self.ui.config('notify', hooktype) or
117 self.ui.config('notify', 'template'))
119 self.ui.config('notify', 'template'))
118 self.t = cmdutil.changeset_templater(self.ui, self.repo,
120 self.t = cmdutil.changeset_templater(self.ui, self.repo,
119 False, None, mapfile, False)
121 False, None, mapfile, False)
120 if not mapfile and not template:
122 if not mapfile and not template:
121 template = deftemplates.get(hooktype) or single_template
123 template = deftemplates.get(hooktype) or single_template
122 if template:
124 if template:
123 template = templater.parsestring(template, quoted=False)
125 template = templater.parsestring(template, quoted=False)
124 self.t.use_template(template)
126 self.t.use_template(template)
125
127
126 def strip(self, path):
128 def strip(self, path):
127 '''strip leading slashes from local path, turn into web-safe path.'''
129 '''strip leading slashes from local path, turn into web-safe path.'''
128
130
129 path = util.pconvert(path)
131 path = util.pconvert(path)
130 count = self.stripcount
132 count = self.stripcount
131 while count > 0:
133 while count > 0:
132 c = path.find('/')
134 c = path.find('/')
133 if c == -1:
135 if c == -1:
134 break
136 break
135 path = path[c+1:]
137 path = path[c+1:]
136 count -= 1
138 count -= 1
137 return path
139 return path
138
140
139 def fixmail(self, addr):
141 def fixmail(self, addr):
140 '''try to clean up email addresses.'''
142 '''try to clean up email addresses.'''
141
143
142 addr = util.email(addr.strip())
144 addr = util.email(addr.strip())
143 if self.domain:
145 if self.domain:
144 a = addr.find('@localhost')
146 a = addr.find('@localhost')
145 if a != -1:
147 if a != -1:
146 addr = addr[:a]
148 addr = addr[:a]
147 if '@' not in addr:
149 if '@' not in addr:
148 return addr + '@' + self.domain
150 return addr + '@' + self.domain
149 return addr
151 return addr
150
152
151 def subscribers(self):
153 def subscribers(self):
152 '''return list of email addresses of subscribers to this repo.'''
154 '''return list of email addresses of subscribers to this repo.'''
153 subs = set()
155 subs = set()
154 for user, pats in self.ui.configitems('usersubs'):
156 for user, pats in self.ui.configitems('usersubs'):
155 for pat in pats.split(','):
157 for pat in pats.split(','):
156 if fnmatch.fnmatch(self.repo.root, pat.strip()):
158 if fnmatch.fnmatch(self.repo.root, pat.strip()):
157 subs.add(self.fixmail(user))
159 subs.add(self.fixmail(user))
158 for pat, users in self.ui.configitems('reposubs'):
160 for pat, users in self.ui.configitems('reposubs'):
159 if fnmatch.fnmatch(self.repo.root, pat):
161 if fnmatch.fnmatch(self.repo.root, pat):
160 for user in users.split(','):
162 for user in users.split(','):
161 subs.add(self.fixmail(user))
163 subs.add(self.fixmail(user))
162 return [mail.addressencode(self.ui, s, self.charsets, self.test)
164 return [mail.addressencode(self.ui, s, self.charsets, self.test)
163 for s in sorted(subs)]
165 for s in sorted(subs)]
164
166
165 def url(self, path=None):
167 def url(self, path=None):
166 return self.ui.config('web', 'baseurl') + (path or self.root)
168 return self.ui.config('web', 'baseurl') + (path or self.root)
167
169
168 def node(self, ctx, **props):
170 def node(self, ctx, **props):
169 '''format one changeset.'''
171 '''format one changeset, unless it is a suppressed merge.'''
172 if not self.merge and len(ctx.parents()) > 1:
173 return False
170 self.t.show(ctx, changes=ctx.changeset(),
174 self.t.show(ctx, changes=ctx.changeset(),
171 baseurl=self.ui.config('web', 'baseurl'),
175 baseurl=self.ui.config('web', 'baseurl'),
172 root=self.repo.root, webroot=self.root, **props)
176 root=self.repo.root, webroot=self.root, **props)
177 return True
173
178
174 def skipsource(self, source):
179 def skipsource(self, source):
175 '''true if incoming changes from this source should be skipped.'''
180 '''true if incoming changes from this source should be skipped.'''
176 ok_sources = self.ui.config('notify', 'sources', 'serve').split()
181 ok_sources = self.ui.config('notify', 'sources', 'serve').split()
177 return source not in ok_sources
182 return source not in ok_sources
178
183
179 def send(self, ctx, count, data):
184 def send(self, ctx, count, data):
180 '''send message.'''
185 '''send message.'''
181
186
182 p = email.Parser.Parser()
187 p = email.Parser.Parser()
183 try:
188 try:
184 msg = p.parsestr(data)
189 msg = p.parsestr(data)
185 except email.Errors.MessageParseError, inst:
190 except email.Errors.MessageParseError, inst:
186 raise util.Abort(inst)
191 raise util.Abort(inst)
187
192
188 # store sender and subject
193 # store sender and subject
189 sender, subject = msg['From'], msg['Subject']
194 sender, subject = msg['From'], msg['Subject']
190 del msg['From'], msg['Subject']
195 del msg['From'], msg['Subject']
191
196
192 if not msg.is_multipart():
197 if not msg.is_multipart():
193 # create fresh mime message from scratch
198 # create fresh mime message from scratch
194 # (multipart templates must take care of this themselves)
199 # (multipart templates must take care of this themselves)
195 headers = msg.items()
200 headers = msg.items()
196 payload = msg.get_payload()
201 payload = msg.get_payload()
197 # for notification prefer readability over data precision
202 # for notification prefer readability over data precision
198 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
203 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
199 # reinstate custom headers
204 # reinstate custom headers
200 for k, v in headers:
205 for k, v in headers:
201 msg[k] = v
206 msg[k] = v
202
207
203 msg['Date'] = util.datestr(format="%a, %d %b %Y %H:%M:%S %1%2")
208 msg['Date'] = util.datestr(format="%a, %d %b %Y %H:%M:%S %1%2")
204
209
205 # try to make subject line exist and be useful
210 # try to make subject line exist and be useful
206 if not subject:
211 if not subject:
207 if count > 1:
212 if count > 1:
208 subject = _('%s: %d new changesets') % (self.root, count)
213 subject = _('%s: %d new changesets') % (self.root, count)
209 else:
214 else:
210 s = ctx.description().lstrip().split('\n', 1)[0].rstrip()
215 s = ctx.description().lstrip().split('\n', 1)[0].rstrip()
211 subject = '%s: %s' % (self.root, s)
216 subject = '%s: %s' % (self.root, s)
212 maxsubject = int(self.ui.config('notify', 'maxsubject', 67))
217 maxsubject = int(self.ui.config('notify', 'maxsubject', 67))
213 if maxsubject and len(subject) > maxsubject:
218 if maxsubject and len(subject) > maxsubject:
214 subject = subject[:maxsubject-3] + '...'
219 subject = subject[:maxsubject-3] + '...'
215 msg['Subject'] = mail.headencode(self.ui, subject,
220 msg['Subject'] = mail.headencode(self.ui, subject,
216 self.charsets, self.test)
221 self.charsets, self.test)
217
222
218 # try to make message have proper sender
223 # try to make message have proper sender
219 if not sender:
224 if not sender:
220 sender = self.ui.config('email', 'from') or self.ui.username()
225 sender = self.ui.config('email', 'from') or self.ui.username()
221 if '@' not in sender or '@localhost' in sender:
226 if '@' not in sender or '@localhost' in sender:
222 sender = self.fixmail(sender)
227 sender = self.fixmail(sender)
223 msg['From'] = mail.addressencode(self.ui, sender,
228 msg['From'] = mail.addressencode(self.ui, sender,
224 self.charsets, self.test)
229 self.charsets, self.test)
225
230
226 msg['X-Hg-Notification'] = 'changeset %s' % ctx
231 msg['X-Hg-Notification'] = 'changeset %s' % ctx
227 if not msg['Message-Id']:
232 if not msg['Message-Id']:
228 msg['Message-Id'] = ('<hg.%s.%s.%s@%s>' %
233 msg['Message-Id'] = ('<hg.%s.%s.%s@%s>' %
229 (ctx, int(time.time()),
234 (ctx, int(time.time()),
230 hash(self.repo.root), socket.getfqdn()))
235 hash(self.repo.root), socket.getfqdn()))
231 msg['To'] = ', '.join(self.subs)
236 msg['To'] = ', '.join(self.subs)
232
237
233 msgtext = msg.as_string()
238 msgtext = msg.as_string()
234 if self.test:
239 if self.test:
235 self.ui.write(msgtext)
240 self.ui.write(msgtext)
236 if not msgtext.endswith('\n'):
241 if not msgtext.endswith('\n'):
237 self.ui.write('\n')
242 self.ui.write('\n')
238 else:
243 else:
239 self.ui.status(_('notify: sending %d subscribers %d changes\n') %
244 self.ui.status(_('notify: sending %d subscribers %d changes\n') %
240 (len(self.subs), count))
245 (len(self.subs), count))
241 mail.sendmail(self.ui, util.email(msg['From']),
246 mail.sendmail(self.ui, util.email(msg['From']),
242 self.subs, msgtext)
247 self.subs, msgtext)
243
248
244 def diff(self, ctx, ref=None):
249 def diff(self, ctx, ref=None):
245
250
246 maxdiff = int(self.ui.config('notify', 'maxdiff', 300))
251 maxdiff = int(self.ui.config('notify', 'maxdiff', 300))
247 prev = ctx.parents()[0].node()
252 prev = ctx.parents()[0].node()
248 ref = ref and ref.node() or ctx.node()
253 ref = ref and ref.node() or ctx.node()
249 chunks = patch.diff(self.repo, prev, ref, opts=patch.diffopts(self.ui))
254 chunks = patch.diff(self.repo, prev, ref, opts=patch.diffopts(self.ui))
250 difflines = ''.join(chunks).splitlines()
255 difflines = ''.join(chunks).splitlines()
251
256
252 if self.ui.configbool('notify', 'diffstat', True):
257 if self.ui.configbool('notify', 'diffstat', True):
253 s = patch.diffstat(difflines)
258 s = patch.diffstat(difflines)
254 # s may be nil, don't include the header if it is
259 # s may be nil, don't include the header if it is
255 if s:
260 if s:
256 self.ui.write('\ndiffstat:\n\n%s' % s)
261 self.ui.write('\ndiffstat:\n\n%s' % s)
257
262
258 if maxdiff == 0:
263 if maxdiff == 0:
259 return
264 return
260 elif maxdiff > 0 and len(difflines) > maxdiff:
265 elif maxdiff > 0 and len(difflines) > maxdiff:
261 msg = _('\ndiffs (truncated from %d to %d lines):\n\n')
266 msg = _('\ndiffs (truncated from %d to %d lines):\n\n')
262 self.ui.write(msg % (len(difflines), maxdiff))
267 self.ui.write(msg % (len(difflines), maxdiff))
263 difflines = difflines[:maxdiff]
268 difflines = difflines[:maxdiff]
264 elif difflines:
269 elif difflines:
265 self.ui.write(_('\ndiffs (%d lines):\n\n') % len(difflines))
270 self.ui.write(_('\ndiffs (%d lines):\n\n') % len(difflines))
266
271
267 self.ui.write("\n".join(difflines))
272 self.ui.write("\n".join(difflines))
268
273
269 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
274 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
270 '''send email notifications to interested subscribers.
275 '''send email notifications to interested subscribers.
271
276
272 if used as changegroup hook, send one email for all changesets in
277 if used as changegroup hook, send one email for all changesets in
273 changegroup. else send one email per changeset.'''
278 changegroup. else send one email per changeset.'''
274
279
275 n = notifier(ui, repo, hooktype)
280 n = notifier(ui, repo, hooktype)
276 ctx = repo[node]
281 ctx = repo[node]
277
282
278 if not n.subs:
283 if not n.subs:
279 ui.debug('notify: no subscribers to repository %s\n' % n.root)
284 ui.debug('notify: no subscribers to repository %s\n' % n.root)
280 return
285 return
281 if n.skipsource(source):
286 if n.skipsource(source):
282 ui.debug('notify: changes have source "%s" - skipping\n' % source)
287 ui.debug('notify: changes have source "%s" - skipping\n' % source)
283 return
288 return
284
289
285 ui.pushbuffer()
290 ui.pushbuffer()
291 data = ''
292 count = 0
286 if hooktype == 'changegroup':
293 if hooktype == 'changegroup':
287 start, end = ctx.rev(), len(repo)
294 start, end = ctx.rev(), len(repo)
288 count = end - start
289 for rev in xrange(start, end):
295 for rev in xrange(start, end):
290 n.node(repo[rev])
296 if n.node(repo[rev]):
291 n.diff(ctx, repo['tip'])
297 count += 1
298 else:
299 data += ui.popbuffer()
300 ui.note(_('notify: suppressing notification for merge %d:%s\n') %
301 (rev, repo[rev].hex()[:12]))
302 ui.pushbuffer()
303 if count:
304 n.diff(ctx, repo['tip'])
292 else:
305 else:
293 count = 1
306 if not n.node(ctx):
294 n.node(ctx)
307 ui.popbuffer()
308 ui.note(_('notify: suppressing notification for merge %d:%s\n') %
309 (ctx.rev(), ctx.hex()[:12]))
310 return
311 count += 1
295 n.diff(ctx)
312 n.diff(ctx)
296
313
297 data = ui.popbuffer()
314 data += ui.popbuffer()
298 n.send(ctx, count, data)
315 if count:
316 n.send(ctx, count, data)
General Comments 0
You need to be logged in to leave comments. Login now