##// END OF EJS Templates
notify: force an exception wrapped by errors.Abort to bytestr...
Matt Harbison -
r50759:5f006f78 default
parent child Browse files
Show More
@@ -1,658 +1,658
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 notify.reply-to-predecessor (EXPERIMENTAL)
137 137 If set and the changeset has a predecessor in the repository, try to thread
138 138 the notification mail with the predecessor. This adds the "In-Reply-To" header
139 139 to the notification mail with a reference to the predecessor with the smallest
140 140 revision number. Mail threads can still be torn, especially when changesets
141 141 are folded.
142 142
143 143 This option must be used in combination with ``notify.messageidseed``.
144 144
145 145 If set, the following entries will also be used to customize the
146 146 notifications:
147 147
148 148 email.from
149 149 Email ``From`` address to use if none can be found in the generated
150 150 email content.
151 151
152 152 web.baseurl
153 153 Root repository URL to combine with repository paths when making
154 154 references. See also ``notify.strip``.
155 155
156 156 '''
157 157
158 158 import email.errors as emailerrors
159 159 import email.utils as emailutils
160 160 import fnmatch
161 161 import hashlib
162 162 import socket
163 163 import time
164 164
165 165 from mercurial.i18n import _
166 166 from mercurial import (
167 167 encoding,
168 168 error,
169 169 logcmdutil,
170 170 mail,
171 171 obsutil,
172 172 patch,
173 173 pycompat,
174 174 registrar,
175 175 util,
176 176 )
177 177 from mercurial.utils import (
178 178 dateutil,
179 179 stringutil,
180 180 )
181 181
182 182 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
183 183 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
184 184 # be specifying the version(s) of Mercurial they are tested with, or
185 185 # leave the attribute unspecified.
186 186 testedwith = b'ships-with-hg-core'
187 187
188 188 configtable = {}
189 189 configitem = registrar.configitem(configtable)
190 190
191 191 configitem(
192 192 b'notify',
193 193 b'changegroup',
194 194 default=None,
195 195 )
196 196 configitem(
197 197 b'notify',
198 198 b'config',
199 199 default=None,
200 200 )
201 201 configitem(
202 202 b'notify',
203 203 b'diffstat',
204 204 default=True,
205 205 )
206 206 configitem(
207 207 b'notify',
208 208 b'domain',
209 209 default=None,
210 210 )
211 211 configitem(
212 212 b'notify',
213 213 b'messageidseed',
214 214 default=None,
215 215 )
216 216 configitem(
217 217 b'notify',
218 218 b'fromauthor',
219 219 default=None,
220 220 )
221 221 configitem(
222 222 b'notify',
223 223 b'incoming',
224 224 default=None,
225 225 )
226 226 configitem(
227 227 b'notify',
228 228 b'maxdiff',
229 229 default=300,
230 230 )
231 231 configitem(
232 232 b'notify',
233 233 b'maxdiffstat',
234 234 default=-1,
235 235 )
236 236 configitem(
237 237 b'notify',
238 238 b'maxsubject',
239 239 default=67,
240 240 )
241 241 configitem(
242 242 b'notify',
243 243 b'mbox',
244 244 default=None,
245 245 )
246 246 configitem(
247 247 b'notify',
248 248 b'merge',
249 249 default=True,
250 250 )
251 251 configitem(
252 252 b'notify',
253 253 b'outgoing',
254 254 default=None,
255 255 )
256 256 configitem(
257 257 b'notify',
258 258 b'reply-to-predecessor',
259 259 default=False,
260 260 )
261 261 configitem(
262 262 b'notify',
263 263 b'sources',
264 264 default=b'serve',
265 265 )
266 266 configitem(
267 267 b'notify',
268 268 b'showfunc',
269 269 default=None,
270 270 )
271 271 configitem(
272 272 b'notify',
273 273 b'strip',
274 274 default=0,
275 275 )
276 276 configitem(
277 277 b'notify',
278 278 b'style',
279 279 default=None,
280 280 )
281 281 configitem(
282 282 b'notify',
283 283 b'template',
284 284 default=None,
285 285 )
286 286 configitem(
287 287 b'notify',
288 288 b'test',
289 289 default=True,
290 290 )
291 291
292 292 # template for single changeset can include email headers.
293 293 single_template = b'''
294 294 Subject: changeset in {webroot}: {desc|firstline|strip}
295 295 From: {author}
296 296
297 297 changeset {node|short} in {root}
298 298 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
299 299 description:
300 300 \t{desc|tabindent|strip}
301 301 '''.lstrip()
302 302
303 303 # template for multiple changesets should not contain email headers,
304 304 # because only first set of headers will be used and result will look
305 305 # strange.
306 306 multiple_template = b'''
307 307 changeset {node|short} in {root}
308 308 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
309 309 summary: {desc|firstline}
310 310 '''
311 311
312 312 deftemplates = {
313 313 b'changegroup': multiple_template,
314 314 }
315 315
316 316
317 317 class notifier:
318 318 '''email notification class.'''
319 319
320 320 def __init__(self, ui, repo, hooktype):
321 321 self.ui = ui
322 322 cfg = self.ui.config(b'notify', b'config')
323 323 if cfg:
324 324 self.ui.readconfig(cfg, sections=[b'usersubs', b'reposubs'])
325 325 self.repo = repo
326 326 self.stripcount = int(self.ui.config(b'notify', b'strip'))
327 327 self.root = self.strip(self.repo.root)
328 328 self.domain = self.ui.config(b'notify', b'domain')
329 329 self.mbox = self.ui.config(b'notify', b'mbox')
330 330 self.test = self.ui.configbool(b'notify', b'test')
331 331 self.charsets = mail._charsets(self.ui)
332 332 self.subs = self.subscribers()
333 333 self.merge = self.ui.configbool(b'notify', b'merge')
334 334 self.showfunc = self.ui.configbool(b'notify', b'showfunc')
335 335 self.messageidseed = self.ui.config(b'notify', b'messageidseed')
336 336 self.reply = self.ui.configbool(b'notify', b'reply-to-predecessor')
337 337
338 338 if self.reply and not self.messageidseed:
339 339 raise error.Abort(
340 340 _(
341 341 b'notify.reply-to-predecessor used without '
342 342 b'notify.messageidseed'
343 343 )
344 344 )
345 345
346 346 if self.showfunc is None:
347 347 self.showfunc = self.ui.configbool(b'diff', b'showfunc')
348 348
349 349 mapfile = None
350 350 template = self.ui.config(b'notify', hooktype) or self.ui.config(
351 351 b'notify', b'template'
352 352 )
353 353 if not template:
354 354 mapfile = self.ui.config(b'notify', b'style')
355 355 if not mapfile and not template:
356 356 template = deftemplates.get(hooktype) or single_template
357 357 spec = logcmdutil.templatespec(template, mapfile)
358 358 self.t = logcmdutil.changesettemplater(self.ui, self.repo, spec)
359 359
360 360 def strip(self, path):
361 361 '''strip leading slashes from local path, turn into web-safe path.'''
362 362
363 363 path = util.pconvert(path)
364 364 count = self.stripcount
365 365 while count > 0:
366 366 c = path.find(b'/')
367 367 if c == -1:
368 368 break
369 369 path = path[c + 1 :]
370 370 count -= 1
371 371 return path
372 372
373 373 def fixmail(self, addr):
374 374 '''try to clean up email addresses.'''
375 375
376 376 addr = stringutil.email(addr.strip())
377 377 if self.domain:
378 378 a = addr.find(b'@localhost')
379 379 if a != -1:
380 380 addr = addr[:a]
381 381 if b'@' not in addr:
382 382 return addr + b'@' + self.domain
383 383 return addr
384 384
385 385 def subscribers(self):
386 386 '''return list of email addresses of subscribers to this repo.'''
387 387 subs = set()
388 388 for user, pats in self.ui.configitems(b'usersubs'):
389 389 for pat in pats.split(b','):
390 390 if b'#' in pat:
391 391 pat, revs = pat.split(b'#', 1)
392 392 else:
393 393 revs = None
394 394 if fnmatch.fnmatch(self.repo.root, pat.strip()):
395 395 subs.add((self.fixmail(user), revs))
396 396 for pat, users in self.ui.configitems(b'reposubs'):
397 397 if b'#' in pat:
398 398 pat, revs = pat.split(b'#', 1)
399 399 else:
400 400 revs = None
401 401 if fnmatch.fnmatch(self.repo.root, pat):
402 402 for user in users.split(b','):
403 403 subs.add((self.fixmail(user), revs))
404 404 return [
405 405 (mail.addressencode(self.ui, s, self.charsets, self.test), r)
406 406 for s, r in sorted(subs)
407 407 ]
408 408
409 409 def node(self, ctx, **props):
410 410 '''format one changeset, unless it is a suppressed merge.'''
411 411 if not self.merge and len(ctx.parents()) > 1:
412 412 return False
413 413 self.t.show(
414 414 ctx,
415 415 changes=ctx.changeset(),
416 416 baseurl=self.ui.config(b'web', b'baseurl'),
417 417 root=self.repo.root,
418 418 webroot=self.root,
419 419 **props
420 420 )
421 421 return True
422 422
423 423 def skipsource(self, source):
424 424 '''true if incoming changes from this source should be skipped.'''
425 425 ok_sources = self.ui.config(b'notify', b'sources').split()
426 426 return source not in ok_sources
427 427
428 428 def send(self, ctx, count, data):
429 429 '''send message.'''
430 430
431 431 # Select subscribers by revset
432 432 subs = set()
433 433 for sub, spec in self.subs:
434 434 if spec is None:
435 435 subs.add(sub)
436 436 continue
437 437 try:
438 438 revs = self.repo.revs(b'%r and %d:', spec, ctx.rev())
439 439 except error.RepoLookupError:
440 440 continue
441 441 if len(revs):
442 442 subs.add(sub)
443 443 continue
444 444 if len(subs) == 0:
445 445 self.ui.debug(
446 446 b'notify: no subscribers to selected repo and revset\n'
447 447 )
448 448 return
449 449
450 450 try:
451 451 msg = mail.parsebytes(data)
452 452 except emailerrors.MessageParseError as inst:
453 raise error.Abort(inst)
453 raise error.Abort(stringutil.forcebytestr(inst))
454 454
455 455 # store sender and subject
456 456 sender = msg['From']
457 457 subject = msg['Subject']
458 458 if sender is not None:
459 459 sender = mail.headdecode(sender)
460 460 if subject is not None:
461 461 subject = mail.headdecode(subject)
462 462 del msg['From'], msg['Subject']
463 463
464 464 if not msg.is_multipart():
465 465 # create fresh mime message from scratch
466 466 # (multipart templates must take care of this themselves)
467 467 headers = msg.items()
468 468 payload = msg.get_payload(decode=True)
469 469 # for notification prefer readability over data precision
470 470 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
471 471 # reinstate custom headers
472 472 for k, v in headers:
473 473 msg[k] = v
474 474
475 475 msg['Date'] = encoding.strfromlocal(
476 476 dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2")
477 477 )
478 478
479 479 # try to make subject line exist and be useful
480 480 if not subject:
481 481 if count > 1:
482 482 subject = _(b'%s: %d new changesets') % (self.root, count)
483 483 else:
484 484 s = ctx.description().lstrip().split(b'\n', 1)[0].rstrip()
485 485 subject = b'%s: %s' % (self.root, s)
486 486 maxsubject = int(self.ui.config(b'notify', b'maxsubject'))
487 487 if maxsubject:
488 488 subject = stringutil.ellipsis(subject, maxsubject)
489 489 msg['Subject'] = mail.headencode(
490 490 self.ui, subject, self.charsets, self.test
491 491 )
492 492
493 493 # try to make message have proper sender
494 494 if not sender:
495 495 sender = self.ui.config(b'email', b'from') or self.ui.username()
496 496 if b'@' not in sender or b'@localhost' in sender:
497 497 sender = self.fixmail(sender)
498 498 msg['From'] = mail.addressencode(
499 499 self.ui, sender, self.charsets, self.test
500 500 )
501 501
502 502 msg['X-Hg-Notification'] = 'changeset %s' % ctx
503 503 if not msg['Message-Id']:
504 504 msg['Message-Id'] = messageid(ctx, self.domain, self.messageidseed)
505 505 if self.reply:
506 506 unfi = self.repo.unfiltered()
507 507 has_node = unfi.changelog.index.has_node
508 508 predecessors = [
509 509 unfi[ctx2]
510 510 for ctx2 in obsutil.allpredecessors(unfi.obsstore, [ctx.node()])
511 511 if ctx2 != ctx.node() and has_node(ctx2)
512 512 ]
513 513 if predecessors:
514 514 # There is at least one predecessor, so which to pick?
515 515 # Ideally, there is a unique root because changesets have
516 516 # been evolved/rebased one step at a time. In this case,
517 517 # just picking the oldest known changeset provides a stable
518 518 # base. It doesn't help when changesets are folded. Any
519 519 # better solution would require storing more information
520 520 # in the repository.
521 521 pred = min(predecessors, key=lambda ctx: ctx.rev())
522 522 msg['In-Reply-To'] = messageid(
523 523 pred, self.domain, self.messageidseed
524 524 )
525 525 msg['To'] = ', '.join(sorted(subs))
526 526
527 527 msgtext = msg.as_bytes()
528 528 if self.test:
529 529 self.ui.write(msgtext)
530 530 if not msgtext.endswith(b'\n'):
531 531 self.ui.write(b'\n')
532 532 else:
533 533 self.ui.status(
534 534 _(b'notify: sending %d subscribers %d changes\n')
535 535 % (len(subs), count)
536 536 )
537 537 mail.sendmail(
538 538 self.ui,
539 539 emailutils.parseaddr(msg['From'])[1],
540 540 subs,
541 541 msgtext,
542 542 mbox=self.mbox,
543 543 )
544 544
545 545 def diff(self, ctx, ref=None):
546 546
547 547 maxdiff = int(self.ui.config(b'notify', b'maxdiff'))
548 548 prev = ctx.p1().node()
549 549 if ref:
550 550 ref = ref.node()
551 551 else:
552 552 ref = ctx.node()
553 553 diffopts = patch.diffallopts(self.ui)
554 554 diffopts.showfunc = self.showfunc
555 555 chunks = patch.diff(self.repo, prev, ref, opts=diffopts)
556 556 difflines = b''.join(chunks).splitlines()
557 557
558 558 if self.ui.configbool(b'notify', b'diffstat'):
559 559 maxdiffstat = int(self.ui.config(b'notify', b'maxdiffstat'))
560 560 s = patch.diffstat(difflines)
561 561 # s may be nil, don't include the header if it is
562 562 if s:
563 563 if maxdiffstat >= 0 and s.count(b"\n") > maxdiffstat + 1:
564 564 s = s.split(b"\n")
565 565 msg = _(b'\ndiffstat (truncated from %d to %d lines):\n\n')
566 566 self.ui.write(msg % (len(s) - 2, maxdiffstat))
567 567 self.ui.write(b"\n".join(s[:maxdiffstat] + s[-2:]))
568 568 else:
569 569 self.ui.write(_(b'\ndiffstat:\n\n%s') % s)
570 570
571 571 if maxdiff == 0:
572 572 return
573 573 elif maxdiff > 0 and len(difflines) > maxdiff:
574 574 msg = _(b'\ndiffs (truncated from %d to %d lines):\n\n')
575 575 self.ui.write(msg % (len(difflines), maxdiff))
576 576 difflines = difflines[:maxdiff]
577 577 elif difflines:
578 578 self.ui.write(_(b'\ndiffs (%d lines):\n\n') % len(difflines))
579 579
580 580 self.ui.write(b"\n".join(difflines))
581 581
582 582
583 583 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
584 584 """send email notifications to interested subscribers.
585 585
586 586 if used as changegroup hook, send one email for all changesets in
587 587 changegroup. else send one email per changeset."""
588 588
589 589 n = notifier(ui, repo, hooktype)
590 590 ctx = repo.unfiltered()[node]
591 591
592 592 if not n.subs:
593 593 ui.debug(b'notify: no subscribers to repository %s\n' % n.root)
594 594 return
595 595 if n.skipsource(source):
596 596 ui.debug(b'notify: changes have source "%s" - skipping\n' % source)
597 597 return
598 598
599 599 ui.pushbuffer()
600 600 data = b''
601 601 count = 0
602 602 author = b''
603 603 if hooktype == b'changegroup' or hooktype == b'outgoing':
604 604 for rev in repo.changelog.revs(start=ctx.rev()):
605 605 if n.node(repo[rev]):
606 606 count += 1
607 607 if not author:
608 608 author = repo[rev].user()
609 609 else:
610 610 data += ui.popbuffer()
611 611 ui.note(
612 612 _(b'notify: suppressing notification for merge %d:%s\n')
613 613 % (rev, repo[rev].hex()[:12])
614 614 )
615 615 ui.pushbuffer()
616 616 if count:
617 617 n.diff(ctx, repo[b'tip'])
618 618 elif ctx.rev() in repo:
619 619 if not n.node(ctx):
620 620 ui.popbuffer()
621 621 ui.note(
622 622 _(b'notify: suppressing notification for merge %d:%s\n')
623 623 % (ctx.rev(), ctx.hex()[:12])
624 624 )
625 625 return
626 626 count += 1
627 627 n.diff(ctx)
628 628 if not author:
629 629 author = ctx.user()
630 630
631 631 data += ui.popbuffer()
632 632 fromauthor = ui.config(b'notify', b'fromauthor')
633 633 if author and fromauthor:
634 634 data = b'\n'.join([b'From: %s' % author, data])
635 635
636 636 if count:
637 637 n.send(ctx, count, data)
638 638
639 639
640 640 def messageid(ctx, domain, messageidseed):
641 641 if domain and messageidseed:
642 642 host = domain
643 643 else:
644 644 host = encoding.strtolocal(socket.getfqdn())
645 645 if messageidseed:
646 646 messagehash = hashlib.sha512(ctx.hex() + messageidseed)
647 647 messageid = b'<hg.%s@%s>' % (
648 648 pycompat.sysbytes(messagehash.hexdigest()[:64]),
649 649 host,
650 650 )
651 651 else:
652 652 messageid = b'<hg.%s.%d.%d@%s>' % (
653 653 ctx,
654 654 int(time.time()),
655 655 hash(ctx.repo().root),
656 656 host,
657 657 )
658 658 return encoding.strfromlocal(messageid)
General Comments 0
You need to be logged in to leave comments. Login now