##// END OF EJS Templates
notify: cast hash to bytes...
Gregory Szorc -
r43434:c3253144 default
parent child Browse files
Show More
@@ -1,570 +1,574
1 1 # notify.py - email notifications for mercurial
2 2 #
3 3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 '''hooks for sending email push notifications
9 9
10 10 This extension implements hooks to send email notifications when
11 11 changesets are sent from or received by the local repository.
12 12
13 13 First, enable the extension as explained in :hg:`help extensions`, and
14 14 register the hook you want to run. ``incoming`` and ``changegroup`` hooks
15 15 are run when changesets are received, while ``outgoing`` hooks are for
16 16 changesets sent to another repository::
17 17
18 18 [hooks]
19 19 # one email for each incoming changeset
20 20 incoming.notify = python:hgext.notify.hook
21 21 # one email for all incoming changesets
22 22 changegroup.notify = python:hgext.notify.hook
23 23
24 24 # one email for all outgoing changesets
25 25 outgoing.notify = python:hgext.notify.hook
26 26
27 27 This registers the hooks. To enable notification, subscribers must
28 28 be assigned to repositories. The ``[usersubs]`` section maps multiple
29 29 repositories to a given recipient. The ``[reposubs]`` section maps
30 30 multiple recipients to a single repository::
31 31
32 32 [usersubs]
33 33 # key is subscriber email, value is a comma-separated list of repo patterns
34 34 user@host = pattern
35 35
36 36 [reposubs]
37 37 # key is repo pattern, value is a comma-separated list of subscriber emails
38 38 pattern = user@host
39 39
40 40 A ``pattern`` is a ``glob`` matching the absolute path to a repository,
41 41 optionally combined with a revset expression. A revset expression, if
42 42 present, is separated from the glob by a hash. Example::
43 43
44 44 [reposubs]
45 45 */widgets#branch(release) = qa-team@example.com
46 46
47 47 This sends to ``qa-team@example.com`` whenever a changeset on the ``release``
48 48 branch triggers a notification in any repository ending in ``widgets``.
49 49
50 50 In order to place them under direct user management, ``[usersubs]`` and
51 51 ``[reposubs]`` sections may be placed in a separate ``hgrc`` file and
52 52 incorporated by reference::
53 53
54 54 [notify]
55 55 config = /path/to/subscriptionsfile
56 56
57 57 Notifications will not be sent until the ``notify.test`` value is set
58 58 to ``False``; see below.
59 59
60 60 Notifications content can be tweaked with the following configuration entries:
61 61
62 62 notify.test
63 63 If ``True``, print messages to stdout instead of sending them. Default: True.
64 64
65 65 notify.sources
66 66 Space-separated list of change sources. Notifications are activated only
67 67 when a changeset's source is in this list. Sources may be:
68 68
69 69 :``serve``: changesets received via http or ssh
70 70 :``pull``: changesets received via ``hg pull``
71 71 :``unbundle``: changesets received via ``hg unbundle``
72 72 :``push``: changesets sent or received via ``hg push``
73 73 :``bundle``: changesets sent via ``hg unbundle``
74 74
75 75 Default: serve.
76 76
77 77 notify.strip
78 78 Number of leading slashes to strip from url paths. By default, notifications
79 79 reference repositories with their absolute path. ``notify.strip`` lets you
80 80 turn them into relative paths. For example, ``notify.strip=3`` will change
81 81 ``/long/path/repository`` into ``repository``. Default: 0.
82 82
83 83 notify.domain
84 84 Default email domain for sender or recipients with no explicit domain.
85 85 It is also used for the domain part of the ``Message-Id`` when using
86 86 ``notify.messageidseed``.
87 87
88 88 notify.messageidseed
89 89 Create deterministic ``Message-Id`` headers for the mails based on the seed
90 90 and the revision identifier of the first commit in the changeset.
91 91
92 92 notify.style
93 93 Style file to use when formatting emails.
94 94
95 95 notify.template
96 96 Template to use when formatting emails.
97 97
98 98 notify.incoming
99 99 Template to use when run as an incoming hook, overriding ``notify.template``.
100 100
101 101 notify.outgoing
102 102 Template to use when run as an outgoing hook, overriding ``notify.template``.
103 103
104 104 notify.changegroup
105 105 Template to use when running as a changegroup hook, overriding
106 106 ``notify.template``.
107 107
108 108 notify.maxdiff
109 109 Maximum number of diff lines to include in notification email. Set to 0
110 110 to disable the diff, or -1 to include all of it. Default: 300.
111 111
112 112 notify.maxdiffstat
113 113 Maximum number of diffstat lines to include in notification email. Set to -1
114 114 to include all of it. Default: -1.
115 115
116 116 notify.maxsubject
117 117 Maximum number of characters in email's subject line. Default: 67.
118 118
119 119 notify.diffstat
120 120 Set to True to include a diffstat before diff content. Default: True.
121 121
122 122 notify.showfunc
123 123 If set, override ``diff.showfunc`` for the diff content. Default: None.
124 124
125 125 notify.merge
126 126 If True, send notifications for merge changesets. Default: True.
127 127
128 128 notify.mbox
129 129 If set, append mails to this mbox file instead of sending. Default: None.
130 130
131 131 notify.fromauthor
132 132 If set, use the committer of the first changeset in a changegroup for
133 133 the "From" field of the notification mail. If not set, take the user
134 134 from the pushing repo. Default: False.
135 135
136 136 If set, the following entries will also be used to customize the
137 137 notifications:
138 138
139 139 email.from
140 140 Email ``From`` address to use if none can be found in the generated
141 141 email content.
142 142
143 143 web.baseurl
144 144 Root repository URL to combine with repository paths when making
145 145 references. See also ``notify.strip``.
146 146
147 147 '''
148 148 from __future__ import absolute_import
149 149
150 150 import email.errors as emailerrors
151 151 import email.parser as emailparser
152 152 import fnmatch
153 153 import hashlib
154 154 import socket
155 155 import time
156 156
157 157 from mercurial.i18n import _
158 158 from mercurial import (
159 159 encoding,
160 160 error,
161 161 logcmdutil,
162 162 mail,
163 163 patch,
164 pycompat,
164 165 registrar,
165 166 util,
166 167 )
167 168 from mercurial.utils import (
168 169 dateutil,
169 170 stringutil,
170 171 )
171 172
172 173 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
173 174 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
174 175 # be specifying the version(s) of Mercurial they are tested with, or
175 176 # leave the attribute unspecified.
176 177 testedwith = b'ships-with-hg-core'
177 178
178 179 configtable = {}
179 180 configitem = registrar.configitem(configtable)
180 181
181 182 configitem(
182 183 b'notify', b'changegroup', default=None,
183 184 )
184 185 configitem(
185 186 b'notify', b'config', default=None,
186 187 )
187 188 configitem(
188 189 b'notify', b'diffstat', default=True,
189 190 )
190 191 configitem(
191 192 b'notify', b'domain', default=None,
192 193 )
193 194 configitem(
194 195 b'notify', b'messageidseed', default=None,
195 196 )
196 197 configitem(
197 198 b'notify', b'fromauthor', default=None,
198 199 )
199 200 configitem(
200 201 b'notify', b'incoming', default=None,
201 202 )
202 203 configitem(
203 204 b'notify', b'maxdiff', default=300,
204 205 )
205 206 configitem(
206 207 b'notify', b'maxdiffstat', default=-1,
207 208 )
208 209 configitem(
209 210 b'notify', b'maxsubject', default=67,
210 211 )
211 212 configitem(
212 213 b'notify', b'mbox', default=None,
213 214 )
214 215 configitem(
215 216 b'notify', b'merge', default=True,
216 217 )
217 218 configitem(
218 219 b'notify', b'outgoing', default=None,
219 220 )
220 221 configitem(
221 222 b'notify', b'sources', default=b'serve',
222 223 )
223 224 configitem(
224 225 b'notify', b'showfunc', default=None,
225 226 )
226 227 configitem(
227 228 b'notify', b'strip', default=0,
228 229 )
229 230 configitem(
230 231 b'notify', b'style', default=None,
231 232 )
232 233 configitem(
233 234 b'notify', b'template', default=None,
234 235 )
235 236 configitem(
236 237 b'notify', b'test', default=True,
237 238 )
238 239
239 240 # template for single changeset can include email headers.
240 241 single_template = b'''
241 242 Subject: changeset in {webroot}: {desc|firstline|strip}
242 243 From: {author}
243 244
244 245 changeset {node|short} in {root}
245 246 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
246 247 description:
247 248 \t{desc|tabindent|strip}
248 249 '''.lstrip()
249 250
250 251 # template for multiple changesets should not contain email headers,
251 252 # because only first set of headers will be used and result will look
252 253 # strange.
253 254 multiple_template = b'''
254 255 changeset {node|short} in {root}
255 256 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
256 257 summary: {desc|firstline}
257 258 '''
258 259
259 260 deftemplates = {
260 261 b'changegroup': multiple_template,
261 262 }
262 263
263 264
264 265 class notifier(object):
265 266 '''email notification class.'''
266 267
267 268 def __init__(self, ui, repo, hooktype):
268 269 self.ui = ui
269 270 cfg = self.ui.config(b'notify', b'config')
270 271 if cfg:
271 272 self.ui.readconfig(cfg, sections=[b'usersubs', b'reposubs'])
272 273 self.repo = repo
273 274 self.stripcount = int(self.ui.config(b'notify', b'strip'))
274 275 self.root = self.strip(self.repo.root)
275 276 self.domain = self.ui.config(b'notify', b'domain')
276 277 self.mbox = self.ui.config(b'notify', b'mbox')
277 278 self.test = self.ui.configbool(b'notify', b'test')
278 279 self.charsets = mail._charsets(self.ui)
279 280 self.subs = self.subscribers()
280 281 self.merge = self.ui.configbool(b'notify', b'merge')
281 282 self.showfunc = self.ui.configbool(b'notify', b'showfunc')
282 283 self.messageidseed = self.ui.config(b'notify', b'messageidseed')
283 284 if self.showfunc is None:
284 285 self.showfunc = self.ui.configbool(b'diff', b'showfunc')
285 286
286 287 mapfile = None
287 288 template = self.ui.config(b'notify', hooktype) or self.ui.config(
288 289 b'notify', b'template'
289 290 )
290 291 if not template:
291 292 mapfile = self.ui.config(b'notify', b'style')
292 293 if not mapfile and not template:
293 294 template = deftemplates.get(hooktype) or single_template
294 295 spec = logcmdutil.templatespec(template, mapfile)
295 296 self.t = logcmdutil.changesettemplater(self.ui, self.repo, spec)
296 297
297 298 def strip(self, path):
298 299 '''strip leading slashes from local path, turn into web-safe path.'''
299 300
300 301 path = util.pconvert(path)
301 302 count = self.stripcount
302 303 while count > 0:
303 304 c = path.find(b'/')
304 305 if c == -1:
305 306 break
306 307 path = path[c + 1 :]
307 308 count -= 1
308 309 return path
309 310
310 311 def fixmail(self, addr):
311 312 '''try to clean up email addresses.'''
312 313
313 314 addr = stringutil.email(addr.strip())
314 315 if self.domain:
315 316 a = addr.find(b'@localhost')
316 317 if a != -1:
317 318 addr = addr[:a]
318 319 if b'@' not in addr:
319 320 return addr + b'@' + self.domain
320 321 return addr
321 322
322 323 def subscribers(self):
323 324 '''return list of email addresses of subscribers to this repo.'''
324 325 subs = set()
325 326 for user, pats in self.ui.configitems(b'usersubs'):
326 327 for pat in pats.split(b','):
327 328 if b'#' in pat:
328 329 pat, revs = pat.split(b'#', 1)
329 330 else:
330 331 revs = None
331 332 if fnmatch.fnmatch(self.repo.root, pat.strip()):
332 333 subs.add((self.fixmail(user), revs))
333 334 for pat, users in self.ui.configitems(b'reposubs'):
334 335 if b'#' in pat:
335 336 pat, revs = pat.split(b'#', 1)
336 337 else:
337 338 revs = None
338 339 if fnmatch.fnmatch(self.repo.root, pat):
339 340 for user in users.split(b','):
340 341 subs.add((self.fixmail(user), revs))
341 342 return [
342 343 (mail.addressencode(self.ui, s, self.charsets, self.test), r)
343 344 for s, r in sorted(subs)
344 345 ]
345 346
346 347 def node(self, ctx, **props):
347 348 '''format one changeset, unless it is a suppressed merge.'''
348 349 if not self.merge and len(ctx.parents()) > 1:
349 350 return False
350 351 self.t.show(
351 352 ctx,
352 353 changes=ctx.changeset(),
353 354 baseurl=self.ui.config(b'web', b'baseurl'),
354 355 root=self.repo.root,
355 356 webroot=self.root,
356 357 **props
357 358 )
358 359 return True
359 360
360 361 def skipsource(self, source):
361 362 '''true if incoming changes from this source should be skipped.'''
362 363 ok_sources = self.ui.config(b'notify', b'sources').split()
363 364 return source not in ok_sources
364 365
365 366 def send(self, ctx, count, data):
366 367 '''send message.'''
367 368
368 369 # Select subscribers by revset
369 370 subs = set()
370 371 for sub, spec in self.subs:
371 372 if spec is None:
372 373 subs.add(sub)
373 374 continue
374 375 revs = self.repo.revs(b'%r and %d:', spec, ctx.rev())
375 376 if len(revs):
376 377 subs.add(sub)
377 378 continue
378 379 if len(subs) == 0:
379 380 self.ui.debug(
380 381 b'notify: no subscribers to selected repo and revset\n'
381 382 )
382 383 return
383 384
384 385 p = emailparser.Parser()
385 386 try:
386 387 msg = p.parsestr(encoding.strfromlocal(data))
387 388 except emailerrors.MessageParseError as inst:
388 389 raise error.Abort(inst)
389 390
390 391 # store sender and subject
391 392 sender = msg[r'From']
392 393 subject = msg[r'Subject']
393 394 if sender is not None:
394 395 sender = encoding.strtolocal(sender)
395 396 if subject is not None:
396 397 subject = encoding.strtolocal(subject)
397 398 del msg[r'From'], msg[r'Subject']
398 399
399 400 if not msg.is_multipart():
400 401 # create fresh mime message from scratch
401 402 # (multipart templates must take care of this themselves)
402 403 headers = msg.items()
403 404 payload = msg.get_payload()
404 405 # for notification prefer readability over data precision
405 406 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
406 407 # reinstate custom headers
407 408 for k, v in headers:
408 409 msg[k] = v
409 410
410 411 msg[r'Date'] = encoding.strfromlocal(
411 412 dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2")
412 413 )
413 414
414 415 # try to make subject line exist and be useful
415 416 if not subject:
416 417 if count > 1:
417 418 subject = _(b'%s: %d new changesets') % (self.root, count)
418 419 else:
419 420 s = ctx.description().lstrip().split(b'\n', 1)[0].rstrip()
420 421 subject = b'%s: %s' % (self.root, s)
421 422 maxsubject = int(self.ui.config(b'notify', b'maxsubject'))
422 423 if maxsubject:
423 424 subject = stringutil.ellipsis(subject, maxsubject)
424 425 msg[r'Subject'] = encoding.strfromlocal(
425 426 mail.headencode(self.ui, subject, self.charsets, self.test)
426 427 )
427 428
428 429 # try to make message have proper sender
429 430 if not sender:
430 431 sender = self.ui.config(b'email', b'from') or self.ui.username()
431 432 if b'@' not in sender or b'@localhost' in sender:
432 433 sender = self.fixmail(sender)
433 434 msg[r'From'] = encoding.strfromlocal(
434 435 mail.addressencode(self.ui, sender, self.charsets, self.test)
435 436 )
436 437
437 438 msg[r'X-Hg-Notification'] = r'changeset %s' % ctx
438 439 if not msg[r'Message-Id']:
439 440 msg[r'Message-Id'] = messageid(ctx, self.domain, self.messageidseed)
440 441 msg[r'To'] = encoding.strfromlocal(b', '.join(sorted(subs)))
441 442
442 443 msgtext = encoding.strtolocal(msg.as_string())
443 444 if self.test:
444 445 self.ui.write(msgtext)
445 446 if not msgtext.endswith(b'\n'):
446 447 self.ui.write(b'\n')
447 448 else:
448 449 self.ui.status(
449 450 _(b'notify: sending %d subscribers %d changes\n')
450 451 % (len(subs), count)
451 452 )
452 453 mail.sendmail(
453 454 self.ui,
454 455 stringutil.email(msg[r'From']),
455 456 subs,
456 457 msgtext,
457 458 mbox=self.mbox,
458 459 )
459 460
460 461 def diff(self, ctx, ref=None):
461 462
462 463 maxdiff = int(self.ui.config(b'notify', b'maxdiff'))
463 464 prev = ctx.p1().node()
464 465 if ref:
465 466 ref = ref.node()
466 467 else:
467 468 ref = ctx.node()
468 469 diffopts = patch.diffallopts(self.ui)
469 470 diffopts.showfunc = self.showfunc
470 471 chunks = patch.diff(self.repo, prev, ref, opts=diffopts)
471 472 difflines = b''.join(chunks).splitlines()
472 473
473 474 if self.ui.configbool(b'notify', b'diffstat'):
474 475 maxdiffstat = int(self.ui.config(b'notify', b'maxdiffstat'))
475 476 s = patch.diffstat(difflines)
476 477 # s may be nil, don't include the header if it is
477 478 if s:
478 479 if maxdiffstat >= 0 and s.count(b"\n") > maxdiffstat + 1:
479 480 s = s.split(b"\n")
480 481 msg = _(b'\ndiffstat (truncated from %d to %d lines):\n\n')
481 482 self.ui.write(msg % (len(s) - 2, maxdiffstat))
482 483 self.ui.write(b"\n".join(s[:maxdiffstat] + s[-2:]))
483 484 else:
484 485 self.ui.write(_(b'\ndiffstat:\n\n%s') % s)
485 486
486 487 if maxdiff == 0:
487 488 return
488 489 elif maxdiff > 0 and len(difflines) > maxdiff:
489 490 msg = _(b'\ndiffs (truncated from %d to %d lines):\n\n')
490 491 self.ui.write(msg % (len(difflines), maxdiff))
491 492 difflines = difflines[:maxdiff]
492 493 elif difflines:
493 494 self.ui.write(_(b'\ndiffs (%d lines):\n\n') % len(difflines))
494 495
495 496 self.ui.write(b"\n".join(difflines))
496 497
497 498
498 499 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
499 500 '''send email notifications to interested subscribers.
500 501
501 502 if used as changegroup hook, send one email for all changesets in
502 503 changegroup. else send one email per changeset.'''
503 504
504 505 n = notifier(ui, repo, hooktype)
505 506 ctx = repo.unfiltered()[node]
506 507
507 508 if not n.subs:
508 509 ui.debug(b'notify: no subscribers to repository %s\n' % n.root)
509 510 return
510 511 if n.skipsource(source):
511 512 ui.debug(b'notify: changes have source "%s" - skipping\n' % source)
512 513 return
513 514
514 515 ui.pushbuffer()
515 516 data = b''
516 517 count = 0
517 518 author = b''
518 519 if hooktype == b'changegroup' or hooktype == b'outgoing':
519 520 for rev in repo.changelog.revs(start=ctx.rev()):
520 521 if n.node(repo[rev]):
521 522 count += 1
522 523 if not author:
523 524 author = repo[rev].user()
524 525 else:
525 526 data += ui.popbuffer()
526 527 ui.note(
527 528 _(b'notify: suppressing notification for merge %d:%s\n')
528 529 % (rev, repo[rev].hex()[:12])
529 530 )
530 531 ui.pushbuffer()
531 532 if count:
532 533 n.diff(ctx, repo[b'tip'])
533 534 elif ctx.rev() in repo:
534 535 if not n.node(ctx):
535 536 ui.popbuffer()
536 537 ui.note(
537 538 _(b'notify: suppressing notification for merge %d:%s\n')
538 539 % (ctx.rev(), ctx.hex()[:12])
539 540 )
540 541 return
541 542 count += 1
542 543 n.diff(ctx)
543 544 if not author:
544 545 author = ctx.user()
545 546
546 547 data += ui.popbuffer()
547 548 fromauthor = ui.config(b'notify', b'fromauthor')
548 549 if author and fromauthor:
549 550 data = b'\n'.join([b'From: %s' % author, data])
550 551
551 552 if count:
552 553 n.send(ctx, count, data)
553 554
554 555
555 556 def messageid(ctx, domain, messageidseed):
556 557 if domain and messageidseed:
557 558 host = domain
558 559 else:
559 560 host = encoding.strtolocal(socket.getfqdn())
560 561 if messageidseed:
561 562 messagehash = hashlib.sha512(ctx.hex() + messageidseed)
562 messageid = b'<hg.%s@%s>' % (messagehash.hexdigest()[:64], host)
563 messageid = b'<hg.%s@%s>' % (
564 pycompat.sysbytes(messagehash.hexdigest()[:64]),
565 host,
566 )
563 567 else:
564 568 messageid = b'<hg.%s.%d.%d@%s>' % (
565 569 ctx,
566 570 int(time.time()),
567 571 hash(ctx.repo().root),
568 572 host,
569 573 )
570 574 return encoding.strfromlocal(messageid)
General Comments 0
You need to be logged in to leave comments. Login now