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