git-multimail: update to release 1.5.0
[git.git] / contrib / hooks / multimail / git_multimail.py
blob8823399e7522e59f9d4f9b9304c1b0ac919cec1f
1 #! /usr/bin/env python
3 __version__ = '1.5.0'
5 # Copyright (c) 2015-2016 Matthieu Moy and others
6 # Copyright (c) 2012-2014 Michael Haggerty and others
7 # Derived from contrib/hooks/post-receive-email, which is
8 # Copyright (c) 2007 Andy Parkins
9 # and also includes contributions by other authors.
11 # This file is part of git-multimail.
13 # git-multimail is free software: you can redistribute it and/or
14 # modify it under the terms of the GNU General Public License version
15 # 2 as published by the Free Software Foundation.
17 # This program is distributed in the hope that it will be useful, but
18 # WITHOUT ANY WARRANTY; without even the implied warranty of
19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
20 # General Public License for more details.
22 # You should have received a copy of the GNU General Public License
23 # along with this program. If not, see
24 # <http://www.gnu.org/licenses/>.
26 """Generate notification emails for pushes to a git repository.
28 This hook sends emails describing changes introduced by pushes to a
29 git repository. For each reference that was changed, it emits one
30 ReferenceChange email summarizing how the reference was changed,
31 followed by one Revision email for each new commit that was introduced
32 by the reference change.
34 Each commit is announced in exactly one Revision email. If the same
35 commit is merged into another branch in the same or a later push, then
36 the ReferenceChange email will list the commit's SHA1 and its one-line
37 summary, but no new Revision email will be generated.
39 This script is designed to be used as a "post-receive" hook in a git
40 repository (see githooks(5)). It can also be used as an "update"
41 script, but this usage is not completely reliable and is deprecated.
43 To help with debugging, this script accepts a --stdout option, which
44 causes the emails to be written to standard output rather than sent
45 using sendmail.
47 See the accompanying README file for the complete documentation.
49 """
51 import sys
52 import os
53 import re
54 import bisect
55 import socket
56 import subprocess
57 import shlex
58 import optparse
59 import logging
60 import smtplib
61 try:
62 import ssl
63 except ImportError:
64 # Python < 2.6 do not have ssl, but that's OK if we don't use it.
65 pass
66 import time
68 import uuid
69 import base64
71 PYTHON3 = sys.version_info >= (3, 0)
73 if sys.version_info <= (2, 5):
74 def all(iterable):
75 for element in iterable:
76 if not element:
77 return False
78 return True
81 def is_ascii(s):
82 return all(ord(c) < 128 and ord(c) > 0 for c in s)
85 if PYTHON3:
86 def is_string(s):
87 return isinstance(s, str)
89 def str_to_bytes(s):
90 return s.encode(ENCODING)
92 def bytes_to_str(s, errors='strict'):
93 return s.decode(ENCODING, errors)
95 unicode = str
97 def write_str(f, msg):
98 # Try outputing with the default encoding. If it fails,
99 # try UTF-8.
100 try:
101 f.buffer.write(msg.encode(sys.getdefaultencoding()))
102 except UnicodeEncodeError:
103 f.buffer.write(msg.encode(ENCODING))
105 def read_line(f):
106 # Try reading with the default encoding. If it fails,
107 # try UTF-8.
108 out = f.buffer.readline()
109 try:
110 return out.decode(sys.getdefaultencoding())
111 except UnicodeEncodeError:
112 return out.decode(ENCODING)
114 import html
116 def html_escape(s):
117 return html.escape(s)
119 else:
120 def is_string(s):
121 try:
122 return isinstance(s, basestring)
123 except NameError: # Silence Pyflakes warning
124 raise
126 def str_to_bytes(s):
127 return s
129 def bytes_to_str(s, errors='strict'):
130 return s
132 def write_str(f, msg):
133 f.write(msg)
135 def read_line(f):
136 return f.readline()
138 def next(it):
139 return it.next()
141 import cgi
143 def html_escape(s):
144 return cgi.escape(s, True)
146 try:
147 from email.charset import Charset
148 from email.utils import make_msgid
149 from email.utils import getaddresses
150 from email.utils import formataddr
151 from email.utils import formatdate
152 from email.header import Header
153 except ImportError:
154 # Prior to Python 2.5, the email module used different names:
155 from email.Charset import Charset
156 from email.Utils import make_msgid
157 from email.Utils import getaddresses
158 from email.Utils import formataddr
159 from email.Utils import formatdate
160 from email.Header import Header
163 DEBUG = False
165 ZEROS = '0' * 40
166 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
167 LOGEND = '-----------------------------------------------------------------------\n'
169 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
171 # It is assumed in many places that the encoding is uniformly UTF-8,
172 # so changing these constants is unsupported. But define them here
173 # anyway, to make it easier to find (at least most of) the places
174 # where the encoding is important.
175 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
178 REF_CREATED_SUBJECT_TEMPLATE = (
179 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
180 ' (now %(newrev_short)s)'
182 REF_UPDATED_SUBJECT_TEMPLATE = (
183 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
184 ' (%(oldrev_short)s -> %(newrev_short)s)'
186 REF_DELETED_SUBJECT_TEMPLATE = (
187 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
188 ' (was %(oldrev_short)s)'
191 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
192 '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
195 REFCHANGE_HEADER_TEMPLATE = """\
196 Date: %(send_date)s
197 To: %(recipients)s
198 Subject: %(subject)s
199 MIME-Version: 1.0
200 Content-Type: text/%(contenttype)s; charset=%(charset)s
201 Content-Transfer-Encoding: 8bit
202 Message-ID: %(msgid)s
203 From: %(fromaddr)s
204 Reply-To: %(reply_to)s
205 Thread-Index: %(thread_index)s
206 X-Git-Host: %(fqdn)s
207 X-Git-Repo: %(repo_shortname)s
208 X-Git-Refname: %(refname)s
209 X-Git-Reftype: %(refname_type)s
210 X-Git-Oldrev: %(oldrev)s
211 X-Git-Newrev: %(newrev)s
212 X-Git-NotificationType: ref_changed
213 X-Git-Multimail-Version: %(multimail_version)s
214 Auto-Submitted: auto-generated
217 REFCHANGE_INTRO_TEMPLATE = """\
218 This is an automated email from the git hooks/post-receive script.
220 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
221 in repository %(repo_shortname)s.
226 FOOTER_TEMPLATE = """\
228 -- \n\
229 To stop receiving notification emails like this one, please contact
230 %(administrator)s.
234 REWIND_ONLY_TEMPLATE = """\
235 This update removed existing revisions from the reference, leaving the
236 reference pointing at a previous point in the repository history.
238 * -- * -- N %(refname)s (%(newrev_short)s)
240 O -- O -- O (%(oldrev_short)s)
242 Any revisions marked "omit" are not gone; other references still
243 refer to them. Any revisions marked "discard" are gone forever.
247 NON_FF_TEMPLATE = """\
248 This update added new revisions after undoing existing revisions.
249 That is to say, some revisions that were in the old version of the
250 %(refname_type)s are not in the new version. This situation occurs
251 when a user --force pushes a change and generates a repository
252 containing something like this:
254 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
256 N -- N -- N %(refname)s (%(newrev_short)s)
258 You should already have received notification emails for all of the O
259 revisions, and so the following emails describe only the N revisions
260 from the common base, B.
262 Any revisions marked "omit" are not gone; other references still
263 refer to them. Any revisions marked "discard" are gone forever.
267 NO_NEW_REVISIONS_TEMPLATE = """\
268 No new revisions were added by this update.
272 DISCARDED_REVISIONS_TEMPLATE = """\
273 This change permanently discards the following revisions:
277 NO_DISCARDED_REVISIONS_TEMPLATE = """\
278 The revisions that were on this %(refname_type)s are still contained in
279 other references; therefore, this change does not discard any commits
280 from the repository.
284 NEW_REVISIONS_TEMPLATE = """\
285 The %(tot)s revisions listed above as "new" are entirely new to this
286 repository and will be described in separate emails. The revisions
287 listed as "add" were already present in the repository and have only
288 been added to this reference.
293 TAG_CREATED_TEMPLATE = """\
294 at %(newrev_short)-8s (%(newrev_type)s)
298 TAG_UPDATED_TEMPLATE = """\
299 *** WARNING: tag %(short_refname)s was modified! ***
301 from %(oldrev_short)-8s (%(oldrev_type)s)
302 to %(newrev_short)-8s (%(newrev_type)s)
306 TAG_DELETED_TEMPLATE = """\
307 *** WARNING: tag %(short_refname)s was deleted! ***
312 # The template used in summary tables. It looks best if this uses the
313 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
314 BRIEF_SUMMARY_TEMPLATE = """\
315 %(action)8s %(rev_short)-8s %(text)s
319 NON_COMMIT_UPDATE_TEMPLATE = """\
320 This is an unusual reference change because the reference did not
321 refer to a commit either before or after the change. We do not know
322 how to provide full information about this reference change.
326 REVISION_HEADER_TEMPLATE = """\
327 Date: %(send_date)s
328 To: %(recipients)s
329 Cc: %(cc_recipients)s
330 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
331 MIME-Version: 1.0
332 Content-Type: text/%(contenttype)s; charset=%(charset)s
333 Content-Transfer-Encoding: 8bit
334 From: %(fromaddr)s
335 Reply-To: %(reply_to)s
336 In-Reply-To: %(reply_to_msgid)s
337 References: %(reply_to_msgid)s
338 Thread-Index: %(thread_index)s
339 X-Git-Host: %(fqdn)s
340 X-Git-Repo: %(repo_shortname)s
341 X-Git-Refname: %(refname)s
342 X-Git-Reftype: %(refname_type)s
343 X-Git-Rev: %(rev)s
344 X-Git-NotificationType: diff
345 X-Git-Multimail-Version: %(multimail_version)s
346 Auto-Submitted: auto-generated
349 REVISION_INTRO_TEMPLATE = """\
350 This is an automated email from the git hooks/post-receive script.
352 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
353 in repository %(repo_shortname)s.
357 LINK_TEXT_TEMPLATE = """\
358 View the commit online:
359 %(browse_url)s
363 LINK_HTML_TEMPLATE = """\
364 <p><a href="%(browse_url)s">View the commit online</a>.</p>
368 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
371 # Combined, meaning refchange+revision email (for single-commit additions)
372 COMBINED_HEADER_TEMPLATE = """\
373 Date: %(send_date)s
374 To: %(recipients)s
375 Subject: %(subject)s
376 MIME-Version: 1.0
377 Content-Type: text/%(contenttype)s; charset=%(charset)s
378 Content-Transfer-Encoding: 8bit
379 Message-ID: %(msgid)s
380 From: %(fromaddr)s
381 Reply-To: %(reply_to)s
382 X-Git-Host: %(fqdn)s
383 X-Git-Repo: %(repo_shortname)s
384 X-Git-Refname: %(refname)s
385 X-Git-Reftype: %(refname_type)s
386 X-Git-Oldrev: %(oldrev)s
387 X-Git-Newrev: %(newrev)s
388 X-Git-Rev: %(rev)s
389 X-Git-NotificationType: ref_changed_plus_diff
390 X-Git-Multimail-Version: %(multimail_version)s
391 Auto-Submitted: auto-generated
394 COMBINED_INTRO_TEMPLATE = """\
395 This is an automated email from the git hooks/post-receive script.
397 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
398 in repository %(repo_shortname)s.
402 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
405 class CommandError(Exception):
406 def __init__(self, cmd, retcode):
407 self.cmd = cmd
408 self.retcode = retcode
409 Exception.__init__(
410 self,
411 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
415 class ConfigurationException(Exception):
416 pass
419 # The "git" program (this could be changed to include a full path):
420 GIT_EXECUTABLE = 'git'
423 # How "git" should be invoked (including global arguments), as a list
424 # of words. This variable is usually initialized automatically by
425 # read_git_output() via choose_git_command(), but if a value is set
426 # here then it will be used unconditionally.
427 GIT_CMD = None
430 def choose_git_command():
431 """Decide how to invoke git, and record the choice in GIT_CMD."""
433 global GIT_CMD
435 if GIT_CMD is None:
436 try:
437 # Check to see whether the "-c" option is accepted (it was
438 # only added in Git 1.7.2). We don't actually use the
439 # output of "git --version", though if we needed more
440 # specific version information this would be the place to
441 # do it.
442 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
443 read_output(cmd)
444 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
445 except CommandError:
446 GIT_CMD = [GIT_EXECUTABLE]
449 def read_git_output(args, input=None, keepends=False, **kw):
450 """Read the output of a Git command."""
452 if GIT_CMD is None:
453 choose_git_command()
455 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
458 def read_output(cmd, input=None, keepends=False, **kw):
459 if input:
460 stdin = subprocess.PIPE
461 input = str_to_bytes(input)
462 else:
463 stdin = None
464 errors = 'strict'
465 if 'errors' in kw:
466 errors = kw['errors']
467 del kw['errors']
468 p = subprocess.Popen(
469 tuple(str_to_bytes(w) for w in cmd),
470 stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
472 (out, err) = p.communicate(input)
473 out = bytes_to_str(out, errors=errors)
474 retcode = p.wait()
475 if retcode:
476 raise CommandError(cmd, retcode)
477 if not keepends:
478 out = out.rstrip('\n\r')
479 return out
482 def read_git_lines(args, keepends=False, **kw):
483 """Return the lines output by Git command.
485 Return as single lines, with newlines stripped off."""
487 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
490 def git_rev_list_ish(cmd, spec, args=None, **kw):
491 """Common functionality for invoking a 'git rev-list'-like command.
493 Parameters:
494 * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
495 * spec is a list of revision arguments to pass to the named
496 command. If None, this function returns an empty list.
497 * args is a list of extra arguments passed to the named command.
498 * All other keyword arguments (if any) are passed to the
499 underlying read_git_lines() function.
501 Return the output of the Git command in the form of a list, one
502 entry per output line.
504 if spec is None:
505 return []
506 if args is None:
507 args = []
508 args = [cmd, '--stdin'] + args
509 spec_stdin = ''.join(s + '\n' for s in spec)
510 return read_git_lines(args, input=spec_stdin, **kw)
513 def git_rev_list(spec, **kw):
514 """Run 'git rev-list' with the given list of revision arguments.
516 See git_rev_list_ish() for parameter and return value
517 documentation.
519 return git_rev_list_ish('rev-list', spec, **kw)
522 def git_log(spec, **kw):
523 """Run 'git log' with the given list of revision arguments.
525 See git_rev_list_ish() for parameter and return value
526 documentation.
528 return git_rev_list_ish('log', spec, **kw)
531 def header_encode(text, header_name=None):
532 """Encode and line-wrap the value of an email header field."""
534 # Convert to unicode, if required.
535 if not isinstance(text, unicode):
536 text = unicode(text, 'utf-8')
538 if is_ascii(text):
539 charset = 'ascii'
540 else:
541 charset = 'utf-8'
543 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
546 def addr_header_encode(text, header_name=None):
547 """Encode and line-wrap the value of an email header field containing
548 email addresses."""
550 # Convert to unicode, if required.
551 if not isinstance(text, unicode):
552 text = unicode(text, 'utf-8')
554 text = ', '.join(
555 formataddr((header_encode(name), emailaddr))
556 for name, emailaddr in getaddresses([text])
559 if is_ascii(text):
560 charset = 'ascii'
561 else:
562 charset = 'utf-8'
564 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
567 class Config(object):
568 def __init__(self, section, git_config=None):
569 """Represent a section of the git configuration.
571 If git_config is specified, it is passed to "git config" in
572 the GIT_CONFIG environment variable, meaning that "git config"
573 will read the specified path rather than the Git default
574 config paths."""
576 self.section = section
577 if git_config:
578 self.env = os.environ.copy()
579 self.env['GIT_CONFIG'] = git_config
580 else:
581 self.env = None
583 @staticmethod
584 def _split(s):
585 """Split NUL-terminated values."""
587 words = s.split('\0')
588 assert words[-1] == ''
589 return words[:-1]
591 @staticmethod
592 def add_config_parameters(c):
593 """Add configuration parameters to Git.
595 c is either an str or a list of str, each element being of the
596 form 'var=val' or 'var', with the same syntax and meaning as
597 the argument of 'git -c var=val'.
599 if isinstance(c, str):
600 c = (c,)
601 parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
602 if parameters:
603 parameters += ' '
604 # git expects GIT_CONFIG_PARAMETERS to be of the form
605 # "'name1=value1' 'name2=value2' 'name3=value3'"
606 # including everything inside the double quotes (but not the double
607 # quotes themselves). Spacing is critical. Also, if a value contains
608 # a literal single quote that quote must be represented using the
609 # four character sequence: '\''
610 parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c)
611 os.environ['GIT_CONFIG_PARAMETERS'] = parameters
613 def get(self, name, default=None):
614 try:
615 values = self._split(read_git_output(
616 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
617 env=self.env, keepends=True,
619 assert len(values) == 1
620 return values[0]
621 except CommandError:
622 return default
624 def get_bool(self, name, default=None):
625 try:
626 value = read_git_output(
627 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
628 env=self.env,
630 except CommandError:
631 return default
632 return value == 'true'
634 def get_all(self, name, default=None):
635 """Read a (possibly multivalued) setting from the configuration.
637 Return the result as a list of values, or default if the name
638 is unset."""
640 try:
641 return self._split(read_git_output(
642 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
643 env=self.env, keepends=True,
645 except CommandError:
646 t, e, traceback = sys.exc_info()
647 if e.retcode == 1:
648 # "the section or key is invalid"; i.e., there is no
649 # value for the specified key.
650 return default
651 else:
652 raise
654 def set(self, name, value):
655 read_git_output(
656 ['config', '%s.%s' % (self.section, name), value],
657 env=self.env,
660 def add(self, name, value):
661 read_git_output(
662 ['config', '--add', '%s.%s' % (self.section, name), value],
663 env=self.env,
666 def __contains__(self, name):
667 return self.get_all(name, default=None) is not None
669 # We don't use this method anymore internally, but keep it here in
670 # case somebody is calling it from their own code:
671 def has_key(self, name):
672 return name in self
674 def unset_all(self, name):
675 try:
676 read_git_output(
677 ['config', '--unset-all', '%s.%s' % (self.section, name)],
678 env=self.env,
680 except CommandError:
681 t, e, traceback = sys.exc_info()
682 if e.retcode == 5:
683 # The name doesn't exist, which is what we wanted anyway...
684 pass
685 else:
686 raise
688 def set_recipients(self, name, value):
689 self.unset_all(name)
690 for pair in getaddresses([value]):
691 self.add(name, formataddr(pair))
694 def generate_summaries(*log_args):
695 """Generate a brief summary for each revision requested.
697 log_args are strings that will be passed directly to "git log" as
698 revision selectors. Iterate over (sha1_short, subject) for each
699 commit specified by log_args (subject is the first line of the
700 commit message as a string without EOLs)."""
702 cmd = [
703 'log', '--abbrev', '--format=%h %s',
704 ] + list(log_args) + ['--']
705 for line in read_git_lines(cmd):
706 yield tuple(line.split(' ', 1))
709 def limit_lines(lines, max_lines):
710 for (index, line) in enumerate(lines):
711 if index < max_lines:
712 yield line
714 if index >= max_lines:
715 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
718 def limit_linelength(lines, max_linelength):
719 for line in lines:
720 # Don't forget that lines always include a trailing newline.
721 if len(line) > max_linelength + 1:
722 line = line[:max_linelength - 7] + ' [...]\n'
723 yield line
726 class CommitSet(object):
727 """A (constant) set of object names.
729 The set should be initialized with full SHA1 object names. The
730 __contains__() method returns True iff its argument is an
731 abbreviation of any the names in the set."""
733 def __init__(self, names):
734 self._names = sorted(names)
736 def __len__(self):
737 return len(self._names)
739 def __contains__(self, sha1_abbrev):
740 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
742 i = bisect.bisect_left(self._names, sha1_abbrev)
743 return i < len(self) and self._names[i].startswith(sha1_abbrev)
746 class GitObject(object):
747 def __init__(self, sha1, type=None):
748 if sha1 == ZEROS:
749 self.sha1 = self.type = self.commit_sha1 = None
750 else:
751 self.sha1 = sha1
752 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
754 if self.type == 'commit':
755 self.commit_sha1 = self.sha1
756 elif self.type == 'tag':
757 try:
758 self.commit_sha1 = read_git_output(
759 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
761 except CommandError:
762 # Cannot deref tag to determine commit_sha1
763 self.commit_sha1 = None
764 else:
765 self.commit_sha1 = None
767 self.short = read_git_output(['rev-parse', '--short', sha1])
769 def get_summary(self):
770 """Return (sha1_short, subject) for this commit."""
772 if not self.sha1:
773 raise ValueError('Empty commit has no summary')
775 return next(iter(generate_summaries('--no-walk', self.sha1)))
777 def __eq__(self, other):
778 return isinstance(other, GitObject) and self.sha1 == other.sha1
780 def __ne__(self, other):
781 return not self == other
783 def __hash__(self):
784 return hash(self.sha1)
786 def __nonzero__(self):
787 return bool(self.sha1)
789 def __bool__(self):
790 """Python 2 backward compatibility"""
791 return self.__nonzero__()
793 def __str__(self):
794 return self.sha1 or ZEROS
797 class Change(object):
798 """A Change that has been made to the Git repository.
800 Abstract class from which both Revisions and ReferenceChanges are
801 derived. A Change knows how to generate a notification email
802 describing itself."""
804 def __init__(self, environment):
805 self.environment = environment
806 self._values = None
807 self._contains_html_diff = False
809 def _contains_diff(self):
810 # We do contain a diff, should it be rendered in HTML?
811 if self.environment.commit_email_format == "html":
812 self._contains_html_diff = True
814 def _compute_values(self):
815 """Return a dictionary {keyword: expansion} for this Change.
817 Derived classes overload this method to add more entries to
818 the return value. This method is used internally by
819 get_values(). The return value should always be a new
820 dictionary."""
822 values = self.environment.get_values()
823 fromaddr = self.environment.get_fromaddr(change=self)
824 if fromaddr is not None:
825 values['fromaddr'] = fromaddr
826 values['multimail_version'] = get_version()
827 return values
829 # Aliases usable in template strings. Tuple of pairs (destination,
830 # source).
831 VALUES_ALIAS = (
832 ("id", "newrev"),
835 def get_values(self, **extra_values):
836 """Return a dictionary {keyword: expansion} for this Change.
838 Return a dictionary mapping keywords to the values that they
839 should be expanded to for this Change (used when interpolating
840 template strings). If any keyword arguments are supplied, add
841 those to the return value as well. The return value is always
842 a new dictionary."""
844 if self._values is None:
845 self._values = self._compute_values()
847 values = self._values.copy()
848 if extra_values:
849 values.update(extra_values)
851 for alias, val in self.VALUES_ALIAS:
852 values[alias] = values[val]
853 return values
855 def expand(self, template, **extra_values):
856 """Expand template.
858 Expand the template (which should be a string) using string
859 interpolation of the values for this Change. If any keyword
860 arguments are provided, also include those in the keywords
861 available for interpolation."""
863 return template % self.get_values(**extra_values)
865 def expand_lines(self, template, html_escape_val=False, **extra_values):
866 """Break template into lines and expand each line."""
868 values = self.get_values(**extra_values)
869 if html_escape_val:
870 for k in values:
871 if is_string(values[k]):
872 values[k] = html_escape(values[k])
873 for line in template.splitlines(True):
874 yield line % values
876 def expand_header_lines(self, template, **extra_values):
877 """Break template into lines and expand each line as an RFC 2822 header.
879 Encode values and split up lines that are too long. Silently
880 skip lines that contain references to unknown variables."""
882 values = self.get_values(**extra_values)
883 if self._contains_html_diff:
884 self._content_type = 'html'
885 else:
886 self._content_type = 'plain'
887 values['contenttype'] = self._content_type
889 for line in template.splitlines():
890 (name, value) = line.split(': ', 1)
892 try:
893 value = value % values
894 except KeyError:
895 t, e, traceback = sys.exc_info()
896 if DEBUG:
897 self.environment.log_warning(
898 'Warning: unknown variable %r in the following line; line skipped:\n'
899 ' %s\n'
900 % (e.args[0], line,)
902 else:
903 if name.lower() in ADDR_HEADERS:
904 value = addr_header_encode(value, name)
905 else:
906 value = header_encode(value, name)
907 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
908 yield splitline
910 def generate_email_header(self):
911 """Generate the RFC 2822 email headers for this Change, a line at a time.
913 The output should not include the trailing blank line."""
915 raise NotImplementedError()
917 def generate_browse_link(self, base_url):
918 """Generate a link to an online repository browser."""
919 return iter(())
921 def generate_email_intro(self, html_escape_val=False):
922 """Generate the email intro for this Change, a line at a time.
924 The output will be used as the standard boilerplate at the top
925 of the email body."""
927 raise NotImplementedError()
929 def generate_email_body(self, push):
930 """Generate the main part of the email body, a line at a time.
932 The text in the body might be truncated after a specified
933 number of lines (see multimailhook.emailmaxlines)."""
935 raise NotImplementedError()
937 def generate_email_footer(self, html_escape_val):
938 """Generate the footer of the email, a line at a time.
940 The footer is always included, irrespective of
941 multimailhook.emailmaxlines."""
943 raise NotImplementedError()
945 def _wrap_for_html(self, lines):
946 """Wrap the lines in HTML <pre> tag when using HTML format.
948 Escape special HTML characters and add <pre> and </pre> tags around
949 the given lines if we should be generating HTML as indicated by
950 self._contains_html_diff being set to true.
952 if self._contains_html_diff:
953 yield "<pre style='margin:0'>\n"
955 for line in lines:
956 yield html_escape(line)
958 yield '</pre>\n'
959 else:
960 for line in lines:
961 yield line
963 def generate_email(self, push, body_filter=None, extra_header_values={}):
964 """Generate an email describing this change.
966 Iterate over the lines (including the header lines) of an
967 email describing this change. If body_filter is not None,
968 then use it to filter the lines that are intended for the
969 email body.
971 The extra_header_values field is received as a dict and not as
972 **kwargs, to allow passing other keyword arguments in the
973 future (e.g. passing extra values to generate_email_intro()"""
975 for line in self.generate_email_header(**extra_header_values):
976 yield line
977 yield '\n'
978 html_escape_val = (self.environment.html_in_intro and
979 self._contains_html_diff)
980 intro = self.generate_email_intro(html_escape_val)
981 if not self.environment.html_in_intro:
982 intro = self._wrap_for_html(intro)
983 for line in intro:
984 yield line
986 if self.environment.commitBrowseURL:
987 for line in self.generate_browse_link(self.environment.commitBrowseURL):
988 yield line
990 body = self.generate_email_body(push)
991 if body_filter is not None:
992 body = body_filter(body)
994 diff_started = False
995 if self._contains_html_diff:
996 # "white-space: pre" is the default, but we need to
997 # specify it again in case the message is viewed in a
998 # webmail which wraps it in an element setting white-space
999 # to something else (Zimbra does this and sets
1000 # white-space: pre-line).
1001 yield '<pre style="white-space: pre; background: #F8F8F8">'
1002 for line in body:
1003 if self._contains_html_diff:
1004 # This is very, very naive. It would be much better to really
1005 # parse the diff, i.e. look at how many lines do we have in
1006 # the hunk headers instead of blindly highlighting everything
1007 # that looks like it might be part of a diff.
1008 bgcolor = ''
1009 fgcolor = ''
1010 if line.startswith('--- a/'):
1011 diff_started = True
1012 bgcolor = 'e0e0ff'
1013 elif line.startswith('diff ') or line.startswith('index '):
1014 diff_started = True
1015 fgcolor = '808080'
1016 elif diff_started:
1017 if line.startswith('+++ '):
1018 bgcolor = 'e0e0ff'
1019 elif line.startswith('@@'):
1020 bgcolor = 'e0e0e0'
1021 elif line.startswith('+'):
1022 bgcolor = 'e0ffe0'
1023 elif line.startswith('-'):
1024 bgcolor = 'ffe0e0'
1025 elif line.startswith('commit '):
1026 fgcolor = '808000'
1027 elif line.startswith(' '):
1028 fgcolor = '404040'
1030 # Chop the trailing LF, we don't want it inside <pre>.
1031 line = html_escape(line[:-1])
1033 if bgcolor or fgcolor:
1034 style = 'display:block; white-space:pre;'
1035 if bgcolor:
1036 style += 'background:#' + bgcolor + ';'
1037 if fgcolor:
1038 style += 'color:#' + fgcolor + ';'
1039 # Use a <span style='display:block> to color the
1040 # whole line. The newline must be inside the span
1041 # to display properly both in Firefox and in
1042 # text-based browser.
1043 line = "<span style='%s'>%s\n</span>" % (style, line)
1044 else:
1045 line = line + '\n'
1047 yield line
1048 if self._contains_html_diff:
1049 yield '</pre>'
1050 html_escape_val = (self.environment.html_in_footer and
1051 self._contains_html_diff)
1052 footer = self.generate_email_footer(html_escape_val)
1053 if not self.environment.html_in_footer:
1054 footer = self._wrap_for_html(footer)
1055 for line in footer:
1056 yield line
1058 def get_specific_fromaddr(self):
1059 """For kinds of Changes which specify it, return the kind-specific
1060 From address to use."""
1061 return None
1064 class Revision(Change):
1065 """A Change consisting of a single git commit."""
1067 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
1069 def __init__(self, reference_change, rev, num, tot):
1070 Change.__init__(self, reference_change.environment)
1071 self.reference_change = reference_change
1072 self.rev = rev
1073 self.change_type = self.reference_change.change_type
1074 self.refname = self.reference_change.refname
1075 self.num = num
1076 self.tot = tot
1077 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
1078 self.recipients = self.environment.get_revision_recipients(self)
1080 # -s is short for --no-patch, but -s works on older git's (e.g. 1.7)
1081 self.parents = read_git_lines(['show', '-s', '--format=%P',
1082 self.rev.sha1])[0].split()
1084 self.cc_recipients = ''
1085 if self.environment.get_scancommitforcc():
1086 self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
1087 if self.cc_recipients:
1088 self.environment.log_msg(
1089 'Add %s to CC for %s' % (self.cc_recipients, self.rev.sha1))
1091 def _cc_recipients(self):
1092 cc_recipients = []
1093 message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
1094 lines = message.strip().split('\n')
1095 for line in lines:
1096 m = re.match(self.CC_RE, line)
1097 if m:
1098 cc_recipients.append(m.group('to'))
1100 return cc_recipients
1102 def _compute_values(self):
1103 values = Change._compute_values(self)
1105 oneline = read_git_output(
1106 ['log', '--format=%s', '--no-walk', self.rev.sha1]
1109 max_subject_length = self.environment.get_max_subject_length()
1110 if max_subject_length > 0 and len(oneline) > max_subject_length:
1111 oneline = oneline[:max_subject_length - 6] + ' [...]'
1113 values['rev'] = self.rev.sha1
1114 values['parents'] = ' '.join(self.parents)
1115 values['rev_short'] = self.rev.short
1116 values['change_type'] = self.change_type
1117 values['refname'] = self.refname
1118 values['newrev'] = self.rev.sha1
1119 values['short_refname'] = self.reference_change.short_refname
1120 values['refname_type'] = self.reference_change.refname_type
1121 values['reply_to_msgid'] = self.reference_change.msgid
1122 values['thread_index'] = self.reference_change.thread_index
1123 values['num'] = self.num
1124 values['tot'] = self.tot
1125 values['recipients'] = self.recipients
1126 if self.cc_recipients:
1127 values['cc_recipients'] = self.cc_recipients
1128 values['oneline'] = oneline
1129 values['author'] = self.author
1131 reply_to = self.environment.get_reply_to_commit(self)
1132 if reply_to:
1133 values['reply_to'] = reply_to
1135 return values
1137 def generate_email_header(self, **extra_values):
1138 for line in self.expand_header_lines(
1139 REVISION_HEADER_TEMPLATE, **extra_values
1141 yield line
1143 def generate_browse_link(self, base_url):
1144 if '%(' not in base_url:
1145 base_url += '%(id)s'
1146 url = "".join(self.expand_lines(base_url))
1147 if self._content_type == 'html':
1148 for line in self.expand_lines(LINK_HTML_TEMPLATE,
1149 html_escape_val=True,
1150 browse_url=url):
1151 yield line
1152 elif self._content_type == 'plain':
1153 for line in self.expand_lines(LINK_TEXT_TEMPLATE,
1154 html_escape_val=False,
1155 browse_url=url):
1156 yield line
1157 else:
1158 raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.")
1160 def generate_email_intro(self, html_escape_val=False):
1161 for line in self.expand_lines(REVISION_INTRO_TEMPLATE,
1162 html_escape_val=html_escape_val):
1163 yield line
1165 def generate_email_body(self, push):
1166 """Show this revision."""
1168 for line in read_git_lines(
1169 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1170 keepends=True,
1171 errors='replace'):
1172 if line.startswith('Date: ') and self.environment.date_substitute:
1173 yield self.environment.date_substitute + line[len('Date: '):]
1174 else:
1175 yield line
1177 def generate_email_footer(self, html_escape_val):
1178 return self.expand_lines(REVISION_FOOTER_TEMPLATE,
1179 html_escape_val=html_escape_val)
1181 def generate_email(self, push, body_filter=None, extra_header_values={}):
1182 self._contains_diff()
1183 return Change.generate_email(self, push, body_filter, extra_header_values)
1185 def get_specific_fromaddr(self):
1186 return self.environment.from_commit
1189 class ReferenceChange(Change):
1190 """A Change to a Git reference.
1192 An abstract class representing a create, update, or delete of a
1193 Git reference. Derived classes handle specific types of reference
1194 (e.g., tags vs. branches). These classes generate the main
1195 reference change email summarizing the reference change and
1196 whether it caused any any commits to be added or removed.
1198 ReferenceChange objects are usually created using the static
1199 create() method, which has the logic to decide which derived class
1200 to instantiate."""
1202 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
1204 @staticmethod
1205 def create(environment, oldrev, newrev, refname):
1206 """Return a ReferenceChange object representing the change.
1208 Return an object that represents the type of change that is being
1209 made. oldrev and newrev should be SHA1s or ZEROS."""
1211 old = GitObject(oldrev)
1212 new = GitObject(newrev)
1213 rev = new or old
1215 # The revision type tells us what type the commit is, combined with
1216 # the location of the ref we can decide between
1217 # - working branch
1218 # - tracking branch
1219 # - unannotated tag
1220 # - annotated tag
1221 m = ReferenceChange.REF_RE.match(refname)
1222 if m:
1223 area = m.group('area')
1224 short_refname = m.group('shortname')
1225 else:
1226 area = ''
1227 short_refname = refname
1229 if rev.type == 'tag':
1230 # Annotated tag:
1231 klass = AnnotatedTagChange
1232 elif rev.type == 'commit':
1233 if area == 'tags':
1234 # Non-annotated tag:
1235 klass = NonAnnotatedTagChange
1236 elif area == 'heads':
1237 # Branch:
1238 klass = BranchChange
1239 elif area == 'remotes':
1240 # Tracking branch:
1241 environment.log_warning(
1242 '*** Push-update of tracking branch %r\n'
1243 '*** - incomplete email generated.'
1244 % (refname,)
1246 klass = OtherReferenceChange
1247 else:
1248 # Some other reference namespace:
1249 environment.log_warning(
1250 '*** Push-update of strange reference %r\n'
1251 '*** - incomplete email generated.'
1252 % (refname,)
1254 klass = OtherReferenceChange
1255 else:
1256 # Anything else (is there anything else?)
1257 environment.log_warning(
1258 '*** Unknown type of update to %r (%s)\n'
1259 '*** - incomplete email generated.'
1260 % (refname, rev.type,)
1262 klass = OtherReferenceChange
1264 return klass(
1265 environment,
1266 refname=refname, short_refname=short_refname,
1267 old=old, new=new, rev=rev,
1270 @staticmethod
1271 def make_thread_index():
1272 """Return a string appropriate for the Thread-Index header,
1273 needed by MS Outlook to get threading right.
1275 The format is (base64-encoded):
1276 - 1 byte must be 1
1277 - 5 bytes encode a date (hardcoded here)
1278 - 16 bytes for a globally unique identifier
1280 FIXME: Unfortunately, even with the Thread-Index field, MS
1281 Outlook doesn't seem to do the threading reliably (see
1282 https://github.com/git-multimail/git-multimail/pull/194).
1284 thread_index = b'\x01\x00\x00\x12\x34\x56' + uuid.uuid4().bytes
1285 return base64.standard_b64encode(thread_index).decode('ascii')
1287 def __init__(self, environment, refname, short_refname, old, new, rev):
1288 Change.__init__(self, environment)
1289 self.change_type = {
1290 (False, True): 'create',
1291 (True, True): 'update',
1292 (True, False): 'delete',
1293 }[bool(old), bool(new)]
1294 self.refname = refname
1295 self.short_refname = short_refname
1296 self.old = old
1297 self.new = new
1298 self.rev = rev
1299 self.msgid = make_msgid()
1300 self.thread_index = self.make_thread_index()
1301 self.diffopts = environment.diffopts
1302 self.graphopts = environment.graphopts
1303 self.logopts = environment.logopts
1304 self.commitlogopts = environment.commitlogopts
1305 self.showgraph = environment.refchange_showgraph
1306 self.showlog = environment.refchange_showlog
1308 self.header_template = REFCHANGE_HEADER_TEMPLATE
1309 self.intro_template = REFCHANGE_INTRO_TEMPLATE
1310 self.footer_template = FOOTER_TEMPLATE
1312 def _compute_values(self):
1313 values = Change._compute_values(self)
1315 values['change_type'] = self.change_type
1316 values['refname_type'] = self.refname_type
1317 values['refname'] = self.refname
1318 values['short_refname'] = self.short_refname
1319 values['msgid'] = self.msgid
1320 values['thread_index'] = self.thread_index
1321 values['recipients'] = self.recipients
1322 values['oldrev'] = str(self.old)
1323 values['oldrev_short'] = self.old.short
1324 values['newrev'] = str(self.new)
1325 values['newrev_short'] = self.new.short
1327 if self.old:
1328 values['oldrev_type'] = self.old.type
1329 if self.new:
1330 values['newrev_type'] = self.new.type
1332 reply_to = self.environment.get_reply_to_refchange(self)
1333 if reply_to:
1334 values['reply_to'] = reply_to
1336 return values
1338 def send_single_combined_email(self, known_added_sha1s):
1339 """Determine if a combined refchange/revision email should be sent
1341 If there is only a single new (non-merge) commit added by a
1342 change, it is useful to combine the ReferenceChange and
1343 Revision emails into one. In such a case, return the single
1344 revision; otherwise, return None.
1346 This method is overridden in BranchChange."""
1348 return None
1350 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1351 """Generate an email describing this change AND specified revision.
1353 Iterate over the lines (including the header lines) of an
1354 email describing this change. If body_filter is not None,
1355 then use it to filter the lines that are intended for the
1356 email body.
1358 The extra_header_values field is received as a dict and not as
1359 **kwargs, to allow passing other keyword arguments in the
1360 future (e.g. passing extra values to generate_email_intro()
1362 This method is overridden in BranchChange."""
1364 raise NotImplementedError
1366 def get_subject(self):
1367 template = {
1368 'create': REF_CREATED_SUBJECT_TEMPLATE,
1369 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1370 'delete': REF_DELETED_SUBJECT_TEMPLATE,
1371 }[self.change_type]
1372 return self.expand(template)
1374 def generate_email_header(self, **extra_values):
1375 if 'subject' not in extra_values:
1376 extra_values['subject'] = self.get_subject()
1378 for line in self.expand_header_lines(
1379 self.header_template, **extra_values
1381 yield line
1383 def generate_email_intro(self, html_escape_val=False):
1384 for line in self.expand_lines(self.intro_template,
1385 html_escape_val=html_escape_val):
1386 yield line
1388 def generate_email_body(self, push):
1389 """Call the appropriate body-generation routine.
1391 Call one of generate_create_summary() /
1392 generate_update_summary() / generate_delete_summary()."""
1394 change_summary = {
1395 'create': self.generate_create_summary,
1396 'delete': self.generate_delete_summary,
1397 'update': self.generate_update_summary,
1398 }[self.change_type](push)
1399 for line in change_summary:
1400 yield line
1402 for line in self.generate_revision_change_summary(push):
1403 yield line
1405 def generate_email_footer(self, html_escape_val):
1406 return self.expand_lines(self.footer_template,
1407 html_escape_val=html_escape_val)
1409 def generate_revision_change_graph(self, push):
1410 if self.showgraph:
1411 args = ['--graph'] + self.graphopts
1412 for newold in ('new', 'old'):
1413 has_newold = False
1414 spec = push.get_commits_spec(newold, self)
1415 for line in git_log(spec, args=args, keepends=True):
1416 if not has_newold:
1417 has_newold = True
1418 yield '\n'
1419 yield 'Graph of %s commits:\n\n' % (
1420 {'new': 'new', 'old': 'discarded'}[newold],)
1421 yield ' ' + line
1422 if has_newold:
1423 yield '\n'
1425 def generate_revision_change_log(self, new_commits_list):
1426 if self.showlog:
1427 yield '\n'
1428 yield 'Detailed log of new commits:\n\n'
1429 for line in read_git_lines(
1430 ['log', '--no-walk'] +
1431 self.logopts +
1432 new_commits_list +
1433 ['--'],
1434 keepends=True,
1436 yield line
1438 def generate_new_revision_summary(self, tot, new_commits_list, push):
1439 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1440 yield line
1441 for line in self.generate_revision_change_graph(push):
1442 yield line
1443 for line in self.generate_revision_change_log(new_commits_list):
1444 yield line
1446 def generate_revision_change_summary(self, push):
1447 """Generate a summary of the revisions added/removed by this change."""
1449 if self.new.commit_sha1 and not self.old.commit_sha1:
1450 # A new reference was created. List the new revisions
1451 # brought by the new reference (i.e., those revisions that
1452 # were not in the repository before this reference
1453 # change).
1454 sha1s = list(push.get_new_commits(self))
1455 sha1s.reverse()
1456 tot = len(sha1s)
1457 new_revisions = [
1458 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1459 for (i, sha1) in enumerate(sha1s)
1462 if new_revisions:
1463 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1464 yield '\n'
1465 for r in new_revisions:
1466 (sha1, subject) = r.rev.get_summary()
1467 yield r.expand(
1468 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1470 yield '\n'
1471 for line in self.generate_new_revision_summary(
1472 tot, [r.rev.sha1 for r in new_revisions], push):
1473 yield line
1474 else:
1475 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1476 yield line
1478 elif self.new.commit_sha1 and self.old.commit_sha1:
1479 # A reference was changed to point at a different commit.
1480 # List the revisions that were removed and/or added *from
1481 # that reference* by this reference change, along with a
1482 # diff between the trees for its old and new values.
1484 # List of the revisions that were added to the branch by
1485 # this update. Note this list can include revisions that
1486 # have already had notification emails; we want such
1487 # revisions in the summary even though we will not send
1488 # new notification emails for them.
1489 adds = list(generate_summaries(
1490 '--topo-order', '--reverse', '%s..%s'
1491 % (self.old.commit_sha1, self.new.commit_sha1,)
1494 # List of the revisions that were removed from the branch
1495 # by this update. This will be empty except for
1496 # non-fast-forward updates.
1497 discards = list(generate_summaries(
1498 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1501 if adds:
1502 new_commits_list = push.get_new_commits(self)
1503 else:
1504 new_commits_list = []
1505 new_commits = CommitSet(new_commits_list)
1507 if discards:
1508 discarded_commits = CommitSet(push.get_discarded_commits(self))
1509 else:
1510 discarded_commits = CommitSet([])
1512 if discards and adds:
1513 for (sha1, subject) in discards:
1514 if sha1 in discarded_commits:
1515 action = 'discard'
1516 else:
1517 action = 'omit'
1518 yield self.expand(
1519 BRIEF_SUMMARY_TEMPLATE, action=action,
1520 rev_short=sha1, text=subject,
1522 for (sha1, subject) in adds:
1523 if sha1 in new_commits:
1524 action = 'new'
1525 else:
1526 action = 'add'
1527 yield self.expand(
1528 BRIEF_SUMMARY_TEMPLATE, action=action,
1529 rev_short=sha1, text=subject,
1531 yield '\n'
1532 for line in self.expand_lines(NON_FF_TEMPLATE):
1533 yield line
1535 elif discards:
1536 for (sha1, subject) in discards:
1537 if sha1 in discarded_commits:
1538 action = 'discard'
1539 else:
1540 action = 'omit'
1541 yield self.expand(
1542 BRIEF_SUMMARY_TEMPLATE, action=action,
1543 rev_short=sha1, text=subject,
1545 yield '\n'
1546 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1547 yield line
1549 elif adds:
1550 (sha1, subject) = self.old.get_summary()
1551 yield self.expand(
1552 BRIEF_SUMMARY_TEMPLATE, action='from',
1553 rev_short=sha1, text=subject,
1555 for (sha1, subject) in adds:
1556 if sha1 in new_commits:
1557 action = 'new'
1558 else:
1559 action = 'add'
1560 yield self.expand(
1561 BRIEF_SUMMARY_TEMPLATE, action=action,
1562 rev_short=sha1, text=subject,
1565 yield '\n'
1567 if new_commits:
1568 for line in self.generate_new_revision_summary(
1569 len(new_commits), new_commits_list, push):
1570 yield line
1571 else:
1572 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1573 yield line
1574 for line in self.generate_revision_change_graph(push):
1575 yield line
1577 # The diffstat is shown from the old revision to the new
1578 # revision. This is to show the truth of what happened in
1579 # this change. There's no point showing the stat from the
1580 # base to the new revision because the base is effectively a
1581 # random revision at this point - the user will be interested
1582 # in what this revision changed - including the undoing of
1583 # previous revisions in the case of non-fast-forward updates.
1584 yield '\n'
1585 yield 'Summary of changes:\n'
1586 for line in read_git_lines(
1587 ['diff-tree'] +
1588 self.diffopts +
1589 ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1590 keepends=True,
1592 yield line
1594 elif self.old.commit_sha1 and not self.new.commit_sha1:
1595 # A reference was deleted. List the revisions that were
1596 # removed from the repository by this reference change.
1598 sha1s = list(push.get_discarded_commits(self))
1599 tot = len(sha1s)
1600 discarded_revisions = [
1601 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1602 for (i, sha1) in enumerate(sha1s)
1605 if discarded_revisions:
1606 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1607 yield line
1608 yield '\n'
1609 for r in discarded_revisions:
1610 (sha1, subject) = r.rev.get_summary()
1611 yield r.expand(
1612 BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject,
1614 for line in self.generate_revision_change_graph(push):
1615 yield line
1616 else:
1617 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1618 yield line
1620 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1621 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1622 yield line
1624 def generate_create_summary(self, push):
1625 """Called for the creation of a reference."""
1627 # This is a new reference and so oldrev is not valid
1628 (sha1, subject) = self.new.get_summary()
1629 yield self.expand(
1630 BRIEF_SUMMARY_TEMPLATE, action='at',
1631 rev_short=sha1, text=subject,
1633 yield '\n'
1635 def generate_update_summary(self, push):
1636 """Called for the change of a pre-existing branch."""
1638 return iter([])
1640 def generate_delete_summary(self, push):
1641 """Called for the deletion of any type of reference."""
1643 (sha1, subject) = self.old.get_summary()
1644 yield self.expand(
1645 BRIEF_SUMMARY_TEMPLATE, action='was',
1646 rev_short=sha1, text=subject,
1648 yield '\n'
1650 def get_specific_fromaddr(self):
1651 return self.environment.from_refchange
1654 class BranchChange(ReferenceChange):
1655 refname_type = 'branch'
1657 def __init__(self, environment, refname, short_refname, old, new, rev):
1658 ReferenceChange.__init__(
1659 self, environment,
1660 refname=refname, short_refname=short_refname,
1661 old=old, new=new, rev=rev,
1663 self.recipients = environment.get_refchange_recipients(self)
1664 self._single_revision = None
1666 def send_single_combined_email(self, known_added_sha1s):
1667 if not self.environment.combine_when_single_commit:
1668 return None
1670 # In the sadly-all-too-frequent usecase of people pushing only
1671 # one of their commits at a time to a repository, users feel
1672 # the reference change summary emails are noise rather than
1673 # important signal. This is because, in this particular
1674 # usecase, there is a reference change summary email for each
1675 # new commit, and all these summaries do is point out that
1676 # there is one new commit (which can readily be inferred by
1677 # the existence of the individual revision email that is also
1678 # sent). In such cases, our users prefer there to be a combined
1679 # reference change summary/new revision email.
1681 # So, if the change is an update and it doesn't discard any
1682 # commits, and it adds exactly one non-merge commit (gerrit
1683 # forces a workflow where every commit is individually merged
1684 # and the git-multimail hook fired off for just this one
1685 # change), then we send a combined refchange/revision email.
1686 try:
1687 # If this change is a reference update that doesn't discard
1688 # any commits...
1689 if self.change_type != 'update':
1690 return None
1692 if read_git_lines(
1693 ['merge-base', self.old.sha1, self.new.sha1]
1694 ) != [self.old.sha1]:
1695 return None
1697 # Check if this update introduced exactly one non-merge
1698 # commit:
1700 def split_line(line):
1701 """Split line into (sha1, [parent,...])."""
1703 words = line.split()
1704 return (words[0], words[1:])
1706 # Get the new commits introduced by the push as a list of
1707 # (sha1, [parent,...])
1708 new_commits = [
1709 split_line(line)
1710 for line in read_git_lines(
1712 'log', '-3', '--format=%H %P',
1713 '%s..%s' % (self.old.sha1, self.new.sha1),
1718 if not new_commits:
1719 return None
1721 # If the newest commit is a merge, save it for a later check
1722 # but otherwise ignore it
1723 merge = None
1724 tot = len(new_commits)
1725 if len(new_commits[0][1]) > 1:
1726 merge = new_commits[0][0]
1727 del new_commits[0]
1729 # Our primary check: we can't combine if more than one commit
1730 # is introduced. We also currently only combine if the new
1731 # commit is a non-merge commit, though it may make sense to
1732 # combine if it is a merge as well.
1733 if not (
1734 len(new_commits) == 1 and
1735 len(new_commits[0][1]) == 1 and
1736 new_commits[0][0] in known_added_sha1s
1738 return None
1740 # We do not want to combine revision and refchange emails if
1741 # those go to separate locations.
1742 rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1743 if rev.recipients != self.recipients:
1744 return None
1746 # We ignored the newest commit if it was just a merge of the one
1747 # commit being introduced. But we don't want to ignore that
1748 # merge commit it it involved conflict resolutions. Check that.
1749 if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1750 return None
1752 # We can combine the refchange and one new revision emails
1753 # into one. Return the Revision that a combined email should
1754 # be sent about.
1755 return rev
1756 except CommandError:
1757 # Cannot determine number of commits in old..new or new..old;
1758 # don't combine reference/revision emails:
1759 return None
1761 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1762 values = revision.get_values()
1763 if extra_header_values:
1764 values.update(extra_header_values)
1765 if 'subject' not in extra_header_values:
1766 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1768 self._single_revision = revision
1769 self._contains_diff()
1770 self.header_template = COMBINED_HEADER_TEMPLATE
1771 self.intro_template = COMBINED_INTRO_TEMPLATE
1772 self.footer_template = COMBINED_FOOTER_TEMPLATE
1774 def revision_gen_link(base_url):
1775 # revision is used only to generate the body, and
1776 # _content_type is set while generating headers. Get it
1777 # from the BranchChange object.
1778 revision._content_type = self._content_type
1779 return revision.generate_browse_link(base_url)
1780 self.generate_browse_link = revision_gen_link
1781 for line in self.generate_email(push, body_filter, values):
1782 yield line
1784 def generate_email_body(self, push):
1785 '''Call the appropriate body generation routine.
1787 If this is a combined refchange/revision email, the special logic
1788 for handling this combined email comes from this function. For
1789 other cases, we just use the normal handling.'''
1791 # If self._single_revision isn't set; don't override
1792 if not self._single_revision:
1793 for line in super(BranchChange, self).generate_email_body(push):
1794 yield line
1795 return
1797 # This is a combined refchange/revision email; we first provide
1798 # some info from the refchange portion, and then call the revision
1799 # generate_email_body function to handle the revision portion.
1800 adds = list(generate_summaries(
1801 '--topo-order', '--reverse', '%s..%s'
1802 % (self.old.commit_sha1, self.new.commit_sha1,)
1805 yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1806 for (sha1, subject) in adds:
1807 yield self.expand(
1808 BRIEF_SUMMARY_TEMPLATE, action='new',
1809 rev_short=sha1, text=subject,
1812 yield self._single_revision.rev.short + " is described below\n"
1813 yield '\n'
1815 for line in self._single_revision.generate_email_body(push):
1816 yield line
1819 class AnnotatedTagChange(ReferenceChange):
1820 refname_type = 'annotated tag'
1822 def __init__(self, environment, refname, short_refname, old, new, rev):
1823 ReferenceChange.__init__(
1824 self, environment,
1825 refname=refname, short_refname=short_refname,
1826 old=old, new=new, rev=rev,
1828 self.recipients = environment.get_announce_recipients(self)
1829 self.show_shortlog = environment.announce_show_shortlog
1831 ANNOTATED_TAG_FORMAT = (
1832 '%(*objectname)\n'
1833 '%(*objecttype)\n'
1834 '%(taggername)\n'
1835 '%(taggerdate)'
1838 def describe_tag(self, push):
1839 """Describe the new value of an annotated tag."""
1841 # Use git for-each-ref to pull out the individual fields from
1842 # the tag
1843 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1844 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1847 yield self.expand(
1848 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1849 rev_short=tagobject, text='(%s)' % (tagtype,),
1851 if tagtype == 'commit':
1852 # If the tagged object is a commit, then we assume this is a
1853 # release, and so we calculate which tag this tag is
1854 # replacing
1855 try:
1856 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1857 except CommandError:
1858 prevtag = None
1859 if prevtag:
1860 yield ' replaces %s\n' % (prevtag,)
1861 else:
1862 prevtag = None
1863 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1865 yield ' by %s\n' % (tagger,)
1866 yield ' on %s\n' % (tagged,)
1867 yield '\n'
1869 # Show the content of the tag message; this might contain a
1870 # change log or release notes so is worth displaying.
1871 yield LOGBEGIN
1872 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1873 contents = contents[contents.index('\n') + 1:]
1874 if contents and contents[-1][-1:] != '\n':
1875 contents.append('\n')
1876 for line in contents:
1877 yield line
1879 if self.show_shortlog and tagtype == 'commit':
1880 # Only commit tags make sense to have rev-list operations
1881 # performed on them
1882 yield '\n'
1883 if prevtag:
1884 # Show changes since the previous release
1885 revlist = read_git_output(
1886 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1887 keepends=True,
1889 else:
1890 # No previous tag, show all the changes since time
1891 # began
1892 revlist = read_git_output(
1893 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1894 keepends=True,
1896 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1897 yield line
1899 yield LOGEND
1900 yield '\n'
1902 def generate_create_summary(self, push):
1903 """Called for the creation of an annotated tag."""
1905 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1906 yield line
1908 for line in self.describe_tag(push):
1909 yield line
1911 def generate_update_summary(self, push):
1912 """Called for the update of an annotated tag.
1914 This is probably a rare event and may not even be allowed."""
1916 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1917 yield line
1919 for line in self.describe_tag(push):
1920 yield line
1922 def generate_delete_summary(self, push):
1923 """Called when a non-annotated reference is updated."""
1925 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1926 yield line
1928 yield self.expand(' tag was %(oldrev_short)s\n')
1929 yield '\n'
1932 class NonAnnotatedTagChange(ReferenceChange):
1933 refname_type = 'tag'
1935 def __init__(self, environment, refname, short_refname, old, new, rev):
1936 ReferenceChange.__init__(
1937 self, environment,
1938 refname=refname, short_refname=short_refname,
1939 old=old, new=new, rev=rev,
1941 self.recipients = environment.get_refchange_recipients(self)
1943 def generate_create_summary(self, push):
1944 """Called for the creation of an annotated tag."""
1946 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1947 yield line
1949 def generate_update_summary(self, push):
1950 """Called when a non-annotated reference is updated."""
1952 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1953 yield line
1955 def generate_delete_summary(self, push):
1956 """Called when a non-annotated reference is updated."""
1958 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1959 yield line
1961 for line in ReferenceChange.generate_delete_summary(self, push):
1962 yield line
1965 class OtherReferenceChange(ReferenceChange):
1966 refname_type = 'reference'
1968 def __init__(self, environment, refname, short_refname, old, new, rev):
1969 # We use the full refname as short_refname, because otherwise
1970 # the full name of the reference would not be obvious from the
1971 # text of the email.
1972 ReferenceChange.__init__(
1973 self, environment,
1974 refname=refname, short_refname=refname,
1975 old=old, new=new, rev=rev,
1977 self.recipients = environment.get_refchange_recipients(self)
1980 class Mailer(object):
1981 """An object that can send emails."""
1983 def __init__(self, environment):
1984 self.environment = environment
1986 def close(self):
1987 pass
1989 def send(self, lines, to_addrs):
1990 """Send an email consisting of lines.
1992 lines must be an iterable over the lines constituting the
1993 header and body of the email. to_addrs is a list of recipient
1994 addresses (can be needed even if lines already contains a
1995 "To:" field). It can be either a string (comma-separated list
1996 of email addresses) or a Python list of individual email
1997 addresses.
2001 raise NotImplementedError()
2004 class SendMailer(Mailer):
2005 """Send emails using 'sendmail -oi -t'."""
2007 SENDMAIL_CANDIDATES = [
2008 '/usr/sbin/sendmail',
2009 '/usr/lib/sendmail',
2012 @staticmethod
2013 def find_sendmail():
2014 for path in SendMailer.SENDMAIL_CANDIDATES:
2015 if os.access(path, os.X_OK):
2016 return path
2017 else:
2018 raise ConfigurationException(
2019 'No sendmail executable found. '
2020 'Try setting multimailhook.sendmailCommand.'
2023 def __init__(self, environment, command=None, envelopesender=None):
2024 """Construct a SendMailer instance.
2026 command should be the command and arguments used to invoke
2027 sendmail, as a list of strings. If an envelopesender is
2028 provided, it will also be passed to the command, via '-f
2029 envelopesender'."""
2030 super(SendMailer, self).__init__(environment)
2031 if command:
2032 self.command = command[:]
2033 else:
2034 self.command = [self.find_sendmail(), '-oi', '-t']
2036 if envelopesender:
2037 self.command.extend(['-f', envelopesender])
2039 def send(self, lines, to_addrs):
2040 try:
2041 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
2042 except OSError:
2043 self.environment.get_logger().error(
2044 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
2045 '*** %s\n' % sys.exc_info()[1] +
2046 '*** Try setting multimailhook.mailer to "smtp"\n' +
2047 '*** to send emails without using the sendmail command.\n'
2049 sys.exit(1)
2050 try:
2051 lines = (str_to_bytes(line) for line in lines)
2052 p.stdin.writelines(lines)
2053 except Exception:
2054 self.environment.get_logger().error(
2055 '*** Error while generating commit email\n'
2056 '*** - mail sending aborted.\n'
2058 if hasattr(p, 'terminate'):
2059 # subprocess.terminate() is not available in Python 2.4
2060 p.terminate()
2061 else:
2062 import signal
2063 os.kill(p.pid, signal.SIGTERM)
2064 raise
2065 else:
2066 p.stdin.close()
2067 retcode = p.wait()
2068 if retcode:
2069 raise CommandError(self.command, retcode)
2072 class SMTPMailer(Mailer):
2073 """Send emails using Python's smtplib."""
2075 def __init__(self, environment,
2076 envelopesender, smtpserver,
2077 smtpservertimeout=10.0, smtpserverdebuglevel=0,
2078 smtpencryption='none',
2079 smtpuser='', smtppass='',
2080 smtpcacerts=''
2082 super(SMTPMailer, self).__init__(environment)
2083 if not envelopesender:
2084 self.environment.get_logger().error(
2085 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
2086 'please set either multimailhook.envelopeSender or user.email\n'
2088 sys.exit(1)
2089 if smtpencryption == 'ssl' and not (smtpuser and smtppass):
2090 raise ConfigurationException(
2091 'Cannot use SMTPMailer with security option ssl '
2092 'without options username and password.'
2094 self.envelopesender = envelopesender
2095 self.smtpserver = smtpserver
2096 self.smtpservertimeout = smtpservertimeout
2097 self.smtpserverdebuglevel = smtpserverdebuglevel
2098 self.security = smtpencryption
2099 self.username = smtpuser
2100 self.password = smtppass
2101 self.smtpcacerts = smtpcacerts
2102 self.loggedin = False
2103 try:
2104 def call(klass, server, timeout):
2105 try:
2106 return klass(server, timeout=timeout)
2107 except TypeError:
2108 # Old Python versions do not have timeout= argument.
2109 return klass(server)
2110 if self.security == 'none':
2111 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2112 elif self.security == 'ssl':
2113 if self.smtpcacerts:
2114 raise smtplib.SMTPException(
2115 "Checking certificate is not supported for ssl, prefer starttls"
2117 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
2118 elif self.security == 'tls':
2119 if 'ssl' not in sys.modules:
2120 self.environment.get_logger().error(
2121 '*** Your Python version does not have the ssl library installed\n'
2122 '*** smtpEncryption=tls is not available.\n'
2123 '*** Either upgrade Python to 2.6 or later\n'
2124 ' or use git_multimail.py version 1.2.\n')
2125 if ':' not in self.smtpserver:
2126 self.smtpserver += ':587' # default port for TLS
2127 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2128 # start: ehlo + starttls
2129 # equivalent to
2130 # self.smtp.ehlo()
2131 # self.smtp.starttls()
2132 # with acces to the ssl layer
2133 self.smtp.ehlo()
2134 if not self.smtp.has_extn("starttls"):
2135 raise smtplib.SMTPException("STARTTLS extension not supported by server")
2136 resp, reply = self.smtp.docmd("STARTTLS")
2137 if resp != 220:
2138 raise smtplib.SMTPException("Wrong answer to the STARTTLS command")
2139 if self.smtpcacerts:
2140 self.smtp.sock = ssl.wrap_socket(
2141 self.smtp.sock,
2142 ca_certs=self.smtpcacerts,
2143 cert_reqs=ssl.CERT_REQUIRED
2145 else:
2146 self.smtp.sock = ssl.wrap_socket(
2147 self.smtp.sock,
2148 cert_reqs=ssl.CERT_NONE
2150 self.environment.get_logger().error(
2151 '*** Warning, the server certificat is not verified (smtp) ***\n'
2152 '*** set the option smtpCACerts ***\n'
2154 if not hasattr(self.smtp.sock, "read"):
2155 # using httplib.FakeSocket with Python 2.5.x or earlier
2156 self.smtp.sock.read = self.smtp.sock.recv
2157 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)
2158 self.smtp.helo_resp = None
2159 self.smtp.ehlo_resp = None
2160 self.smtp.esmtp_features = {}
2161 self.smtp.does_esmtp = 0
2162 # end: ehlo + starttls
2163 self.smtp.ehlo()
2164 else:
2165 sys.stdout.write('*** Error: Control reached an invalid option. ***')
2166 sys.exit(1)
2167 if self.smtpserverdebuglevel > 0:
2168 sys.stdout.write(
2169 "*** Setting debug on for SMTP server connection (%s) ***\n"
2170 % self.smtpserverdebuglevel)
2171 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
2172 except Exception:
2173 self.environment.get_logger().error(
2174 '*** Error establishing SMTP connection to %s ***\n'
2175 '*** %s\n'
2176 % (self.smtpserver, sys.exc_info()[1]))
2177 sys.exit(1)
2179 def close(self):
2180 if hasattr(self, 'smtp'):
2181 self.smtp.quit()
2182 del self.smtp
2184 def __del__(self):
2185 self.close()
2187 def send(self, lines, to_addrs):
2188 try:
2189 if self.username or self.password:
2190 if not self.loggedin:
2191 self.smtp.login(self.username, self.password)
2192 self.loggedin = True
2193 msg = ''.join(lines)
2194 # turn comma-separated list into Python list if needed.
2195 if is_string(to_addrs):
2196 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
2197 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
2198 except socket.timeout:
2199 self.environment.get_logger().error(
2200 '*** Error sending email ***\n'
2201 '*** SMTP server timed out (timeout is %s)\n'
2202 % self.smtpservertimeout)
2203 except smtplib.SMTPResponseException:
2204 err = sys.exc_info()[1]
2205 self.environment.get_logger().error(
2206 '*** Error sending email ***\n'
2207 '*** Error %d: %s\n'
2208 % (err.smtp_code, bytes_to_str(err.smtp_error)))
2209 try:
2210 smtp = self.smtp
2211 # delete the field before quit() so that in case of
2212 # error, self.smtp is deleted anyway.
2213 del self.smtp
2214 smtp.quit()
2215 except:
2216 self.environment.get_logger().error(
2217 '*** Error closing the SMTP connection ***\n'
2218 '*** Exiting anyway ... ***\n'
2219 '*** %s\n' % sys.exc_info()[1])
2220 sys.exit(1)
2223 class OutputMailer(Mailer):
2224 """Write emails to an output stream, bracketed by lines of '=' characters.
2226 This is intended for debugging purposes."""
2228 SEPARATOR = '=' * 75 + '\n'
2230 def __init__(self, f, environment=None):
2231 super(OutputMailer, self).__init__(environment=environment)
2232 self.f = f
2234 def send(self, lines, to_addrs):
2235 write_str(self.f, self.SEPARATOR)
2236 for line in lines:
2237 write_str(self.f, line)
2238 write_str(self.f, self.SEPARATOR)
2241 def get_git_dir():
2242 """Determine GIT_DIR.
2244 Determine GIT_DIR either from the GIT_DIR environment variable or
2245 from the working directory, using Git's usual rules."""
2247 try:
2248 return read_git_output(['rev-parse', '--git-dir'])
2249 except CommandError:
2250 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
2251 sys.exit(1)
2254 class Environment(object):
2255 """Describes the environment in which the push is occurring.
2257 An Environment object encapsulates information about the local
2258 environment. For example, it knows how to determine:
2260 * the name of the repository to which the push occurred
2262 * what user did the push
2264 * what users want to be informed about various types of changes.
2266 An Environment object is expected to have the following methods:
2268 get_repo_shortname()
2270 Return a short name for the repository, for display
2271 purposes.
2273 get_repo_path()
2275 Return the absolute path to the Git repository.
2277 get_emailprefix()
2279 Return a string that will be prefixed to every email's
2280 subject.
2282 get_pusher()
2284 Return the username of the person who pushed the changes.
2285 This value is used in the email body to indicate who
2286 pushed the change.
2288 get_pusher_email() (may return None)
2290 Return the email address of the person who pushed the
2291 changes. The value should be a single RFC 2822 email
2292 address as a string; e.g., "Joe User <user@example.com>"
2293 if available, otherwise "user@example.com". If set, the
2294 value is used as the Reply-To address for refchange
2295 emails. If it is impossible to determine the pusher's
2296 email, this attribute should be set to None (in which case
2297 no Reply-To header will be output).
2299 get_sender()
2301 Return the address to be used as the 'From' email address
2302 in the email envelope.
2304 get_fromaddr(change=None)
2306 Return the 'From' email address used in the email 'From:'
2307 headers. If the change is known when this function is
2308 called, it is passed in as the 'change' parameter. (May
2309 be a full RFC 2822 email address like 'Joe User
2310 <user@example.com>'.)
2312 get_administrator()
2314 Return the name and/or email of the repository
2315 administrator. This value is used in the footer as the
2316 person to whom requests to be removed from the
2317 notification list should be sent. Ideally, it should
2318 include a valid email address.
2320 get_reply_to_refchange()
2321 get_reply_to_commit()
2323 Return the address to use in the email "Reply-To" header,
2324 as a string. These can be an RFC 2822 email address, or
2325 None to omit the "Reply-To" header.
2326 get_reply_to_refchange() is used for refchange emails;
2327 get_reply_to_commit() is used for individual commit
2328 emails.
2330 get_ref_filter_regex()
2332 Return a tuple -- a compiled regex, and a boolean indicating
2333 whether the regex picks refs to include (if False, the regex
2334 matches on refs to exclude).
2336 get_default_ref_ignore_regex()
2338 Return a regex that should be ignored for both what emails
2339 to send and when computing what commits are considered new
2340 to the repository. Default is "^refs/notes/".
2342 get_max_subject_length()
2344 Return an int giving the maximal length for the subject
2345 (git log --oneline).
2347 They should also define the following attributes:
2349 announce_show_shortlog (bool)
2351 True iff announce emails should include a shortlog.
2353 commit_email_format (string)
2355 If "html", generate commit emails in HTML instead of plain text
2356 used by default.
2358 html_in_intro (bool)
2359 html_in_footer (bool)
2361 When generating HTML emails, the introduction (respectively,
2362 the footer) will be HTML-escaped iff html_in_intro (respectively,
2363 the footer) is true. When false, only the values used to expand
2364 the template are escaped.
2366 refchange_showgraph (bool)
2368 True iff refchanges emails should include a detailed graph.
2370 refchange_showlog (bool)
2372 True iff refchanges emails should include a detailed log.
2374 diffopts (list of strings)
2376 The options that should be passed to 'git diff' for the
2377 summary email. The value should be a list of strings
2378 representing words to be passed to the command.
2380 graphopts (list of strings)
2382 Analogous to diffopts, but contains options passed to
2383 'git log --graph' when generating the detailed graph for
2384 a set of commits (see refchange_showgraph)
2386 logopts (list of strings)
2388 Analogous to diffopts, but contains options passed to
2389 'git log' when generating the detailed log for a set of
2390 commits (see refchange_showlog)
2392 commitlogopts (list of strings)
2394 The options that should be passed to 'git log' for each
2395 commit mail. The value should be a list of strings
2396 representing words to be passed to the command.
2398 date_substitute (string)
2400 String to be used in substitution for 'Date:' at start of
2401 line in the output of 'git log'.
2403 quiet (bool)
2404 On success do not write to stderr
2406 stdout (bool)
2407 Write email to stdout rather than emailing. Useful for debugging
2409 combine_when_single_commit (bool)
2411 True if a combined email should be produced when a single
2412 new commit is pushed to a branch, False otherwise.
2414 from_refchange, from_commit (strings)
2416 Addresses to use for the From: field for refchange emails
2417 and commit emails respectively. Set from
2418 multimailhook.fromRefchange and multimailhook.fromCommit
2419 by ConfigEnvironmentMixin.
2421 log_file, error_log_file, debug_log_file (string)
2423 Name of a file to which logs should be sent.
2425 verbose (int)
2427 How verbose the system should be.
2428 - 0 (default): show info, errors, ...
2429 - 1 : show basic debug info
2432 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2434 def __init__(self, osenv=None):
2435 self.osenv = osenv or os.environ
2436 self.announce_show_shortlog = False
2437 self.commit_email_format = "text"
2438 self.html_in_intro = False
2439 self.html_in_footer = False
2440 self.commitBrowseURL = None
2441 self.maxcommitemails = 500
2442 self.excludemergerevisions = False
2443 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2444 self.graphopts = ['--oneline', '--decorate']
2445 self.logopts = []
2446 self.refchange_showgraph = False
2447 self.refchange_showlog = False
2448 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2449 self.date_substitute = 'AuthorDate: '
2450 self.quiet = False
2451 self.stdout = False
2452 self.combine_when_single_commit = True
2453 self.logger = None
2455 self.COMPUTED_KEYS = [
2456 'administrator',
2457 'charset',
2458 'emailprefix',
2459 'pusher',
2460 'pusher_email',
2461 'repo_path',
2462 'repo_shortname',
2463 'sender',
2466 self._values = None
2468 def get_logger(self):
2469 """Get (possibly creates) the logger associated to this environment."""
2470 if self.logger is None:
2471 self.logger = Logger(self)
2472 return self.logger
2474 def get_repo_shortname(self):
2475 """Use the last part of the repo path, with ".git" stripped off if present."""
2477 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2478 m = self.REPO_NAME_RE.match(basename)
2479 if m:
2480 return m.group('name')
2481 else:
2482 return basename
2484 def get_pusher(self):
2485 raise NotImplementedError()
2487 def get_pusher_email(self):
2488 return None
2490 def get_fromaddr(self, change=None):
2491 config = Config('user')
2492 fromname = config.get('name', default='')
2493 fromemail = config.get('email', default='')
2494 if fromemail:
2495 return formataddr([fromname, fromemail])
2496 return self.get_sender()
2498 def get_administrator(self):
2499 return 'the administrator of this repository'
2501 def get_emailprefix(self):
2502 return ''
2504 def get_repo_path(self):
2505 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2506 path = get_git_dir()
2507 else:
2508 path = read_git_output(['rev-parse', '--show-toplevel'])
2509 return os.path.abspath(path)
2511 def get_charset(self):
2512 return CHARSET
2514 def get_values(self):
2515 """Return a dictionary {keyword: expansion} for this Environment.
2517 This method is called by Change._compute_values(). The keys
2518 in the returned dictionary are available to be used in any of
2519 the templates. The dictionary is created by calling
2520 self.get_NAME() for each of the attributes named in
2521 COMPUTED_KEYS and recording those that do not return None.
2522 The return value is always a new dictionary."""
2524 if self._values is None:
2525 values = {'': ''} # %()s expands to the empty string.
2527 for key in self.COMPUTED_KEYS:
2528 value = getattr(self, 'get_%s' % (key,))()
2529 if value is not None:
2530 values[key] = value
2532 self._values = values
2534 return self._values.copy()
2536 def get_refchange_recipients(self, refchange):
2537 """Return the recipients for notifications about refchange.
2539 Return the list of email addresses to which notifications
2540 about the specified ReferenceChange should be sent."""
2542 raise NotImplementedError()
2544 def get_announce_recipients(self, annotated_tag_change):
2545 """Return the recipients for notifications about annotated_tag_change.
2547 Return the list of email addresses to which notifications
2548 about the specified AnnotatedTagChange should be sent."""
2550 raise NotImplementedError()
2552 def get_reply_to_refchange(self, refchange):
2553 return self.get_pusher_email()
2555 def get_revision_recipients(self, revision):
2556 """Return the recipients for messages about revision.
2558 Return the list of email addresses to which notifications
2559 about the specified Revision should be sent. This method
2560 could be overridden, for example, to take into account the
2561 contents of the revision when deciding whom to notify about
2562 it. For example, there could be a scheme for users to express
2563 interest in particular files or subdirectories, and only
2564 receive notification emails for revisions that affecting those
2565 files."""
2567 raise NotImplementedError()
2569 def get_reply_to_commit(self, revision):
2570 return revision.author
2572 def get_default_ref_ignore_regex(self):
2573 # The commit messages of git notes are essentially meaningless
2574 # and "filenames" in git notes commits are an implementational
2575 # detail that might surprise users at first. As such, we
2576 # would need a completely different method for handling emails
2577 # of git notes in order for them to be of benefit for users,
2578 # which we simply do not have right now.
2579 return "^refs/notes/"
2581 def get_max_subject_length(self):
2582 """Return the maximal subject line (git log --oneline) length.
2583 Longer subject lines will be truncated."""
2584 raise NotImplementedError()
2586 def filter_body(self, lines):
2587 """Filter the lines intended for an email body.
2589 lines is an iterable over the lines that would go into the
2590 email body. Filter it (e.g., limit the number of lines, the
2591 line length, character set, etc.), returning another iterable.
2592 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2593 for classes implementing this functionality."""
2595 return lines
2597 def log_msg(self, msg):
2598 """Write the string msg on a log file or on stderr.
2600 Sends the text to stderr by default, override to change the behavior."""
2601 self.get_logger().info(msg)
2603 def log_warning(self, msg):
2604 """Write the string msg on a log file or on stderr.
2606 Sends the text to stderr by default, override to change the behavior."""
2607 self.get_logger().warning(msg)
2609 def log_error(self, msg):
2610 """Write the string msg on a log file or on stderr.
2612 Sends the text to stderr by default, override to change the behavior."""
2613 self.get_logger().error(msg)
2615 def check(self):
2616 pass
2619 class ConfigEnvironmentMixin(Environment):
2620 """A mixin that sets self.config to its constructor's config argument.
2622 This class's constructor consumes the "config" argument.
2624 Mixins that need to inspect the config should inherit from this
2625 class (1) to make sure that "config" is still in the constructor
2626 arguments with its own constructor runs and/or (2) to be sure that
2627 self.config is set after construction."""
2629 def __init__(self, config, **kw):
2630 super(ConfigEnvironmentMixin, self).__init__(**kw)
2631 self.config = config
2634 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2635 """An Environment that reads most of its information from "git config"."""
2637 @staticmethod
2638 def forbid_field_values(name, value, forbidden):
2639 for forbidden_val in forbidden:
2640 if value is not None and value.lower() == forbidden:
2641 raise ConfigurationException(
2642 '"%s" is not an allowed setting for %s' % (value, name)
2645 def __init__(self, config, **kw):
2646 super(ConfigOptionsEnvironmentMixin, self).__init__(
2647 config=config, **kw
2650 for var, cfg in (
2651 ('announce_show_shortlog', 'announceshortlog'),
2652 ('refchange_showgraph', 'refchangeShowGraph'),
2653 ('refchange_showlog', 'refchangeshowlog'),
2654 ('quiet', 'quiet'),
2655 ('stdout', 'stdout'),
2657 val = config.get_bool(cfg)
2658 if val is not None:
2659 setattr(self, var, val)
2661 commit_email_format = config.get('commitEmailFormat')
2662 if commit_email_format is not None:
2663 if commit_email_format != "html" and commit_email_format != "text":
2664 self.log_warning(
2665 '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2666 commit_email_format +
2667 '*** Expected either "text" or "html". Ignoring.\n'
2669 else:
2670 self.commit_email_format = commit_email_format
2672 html_in_intro = config.get_bool('htmlInIntro')
2673 if html_in_intro is not None:
2674 self.html_in_intro = html_in_intro
2676 html_in_footer = config.get_bool('htmlInFooter')
2677 if html_in_footer is not None:
2678 self.html_in_footer = html_in_footer
2680 self.commitBrowseURL = config.get('commitBrowseURL')
2682 self.excludemergerevisions = config.get('excludeMergeRevisions')
2684 maxcommitemails = config.get('maxcommitemails')
2685 if maxcommitemails is not None:
2686 try:
2687 self.maxcommitemails = int(maxcommitemails)
2688 except ValueError:
2689 self.log_warning(
2690 '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2691 % maxcommitemails +
2692 '*** Expected a number. Ignoring.\n'
2695 diffopts = config.get('diffopts')
2696 if diffopts is not None:
2697 self.diffopts = shlex.split(diffopts)
2699 graphopts = config.get('graphOpts')
2700 if graphopts is not None:
2701 self.graphopts = shlex.split(graphopts)
2703 logopts = config.get('logopts')
2704 if logopts is not None:
2705 self.logopts = shlex.split(logopts)
2707 commitlogopts = config.get('commitlogopts')
2708 if commitlogopts is not None:
2709 self.commitlogopts = shlex.split(commitlogopts)
2711 date_substitute = config.get('dateSubstitute')
2712 if date_substitute == 'none':
2713 self.date_substitute = None
2714 elif date_substitute is not None:
2715 self.date_substitute = date_substitute
2717 reply_to = config.get('replyTo')
2718 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2719 self.forbid_field_values('replyToRefchange',
2720 self.__reply_to_refchange,
2721 ['author'])
2722 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2724 self.from_refchange = config.get('fromRefchange')
2725 self.forbid_field_values('fromRefchange',
2726 self.from_refchange,
2727 ['author', 'none'])
2728 self.from_commit = config.get('fromCommit')
2729 self.forbid_field_values('fromCommit',
2730 self.from_commit,
2731 ['none'])
2733 combine = config.get_bool('combineWhenSingleCommit')
2734 if combine is not None:
2735 self.combine_when_single_commit = combine
2737 self.log_file = config.get('logFile', default=None)
2738 self.error_log_file = config.get('errorLogFile', default=None)
2739 self.debug_log_file = config.get('debugLogFile', default=None)
2740 if config.get_bool('Verbose', default=False):
2741 self.verbose = 1
2742 else:
2743 self.verbose = 0
2745 def get_administrator(self):
2746 return (
2747 self.config.get('administrator') or
2748 self.get_sender() or
2749 super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2752 def get_repo_shortname(self):
2753 return (
2754 self.config.get('reponame') or
2755 super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2758 def get_emailprefix(self):
2759 emailprefix = self.config.get('emailprefix')
2760 if emailprefix is not None:
2761 emailprefix = emailprefix.strip()
2762 if emailprefix:
2763 emailprefix += ' '
2764 else:
2765 emailprefix = '[%(repo_shortname)s] '
2766 short_name = self.get_repo_shortname()
2767 try:
2768 return emailprefix % {'repo_shortname': short_name}
2769 except:
2770 self.get_logger().error(
2771 '*** Invalid multimailhook.emailPrefix: %s\n' % emailprefix +
2772 '*** %s\n' % sys.exc_info()[1] +
2773 "*** Only the '%(repo_shortname)s' placeholder is allowed\n"
2775 raise ConfigurationException(
2776 '"%s" is not an allowed setting for emailPrefix' % emailprefix
2779 def get_sender(self):
2780 return self.config.get('envelopesender')
2782 def process_addr(self, addr, change):
2783 if addr.lower() == 'author':
2784 if hasattr(change, 'author'):
2785 return change.author
2786 else:
2787 return None
2788 elif addr.lower() == 'pusher':
2789 return self.get_pusher_email()
2790 elif addr.lower() == 'none':
2791 return None
2792 else:
2793 return addr
2795 def get_fromaddr(self, change=None):
2796 fromaddr = self.config.get('from')
2797 if change:
2798 specific_fromaddr = change.get_specific_fromaddr()
2799 if specific_fromaddr:
2800 fromaddr = specific_fromaddr
2801 if fromaddr:
2802 fromaddr = self.process_addr(fromaddr, change)
2803 if fromaddr:
2804 return fromaddr
2805 return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2807 def get_reply_to_refchange(self, refchange):
2808 if self.__reply_to_refchange is None:
2809 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2810 else:
2811 return self.process_addr(self.__reply_to_refchange, refchange)
2813 def get_reply_to_commit(self, revision):
2814 if self.__reply_to_commit is None:
2815 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2816 else:
2817 return self.process_addr(self.__reply_to_commit, revision)
2819 def get_scancommitforcc(self):
2820 return self.config.get('scancommitforcc')
2823 class FilterLinesEnvironmentMixin(Environment):
2824 """Handle encoding and maximum line length of body lines.
2826 email_max_line_length (int or None)
2828 The maximum length of any single line in the email body.
2829 Longer lines are truncated at that length with ' [...]'
2830 appended.
2832 strict_utf8 (bool)
2834 If this field is set to True, then the email body text is
2835 expected to be UTF-8. Any invalid characters are
2836 converted to U+FFFD, the Unicode replacement character
2837 (encoded as UTF-8, of course).
2841 def __init__(self, strict_utf8=True,
2842 email_max_line_length=500, max_subject_length=500,
2843 **kw):
2844 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2845 self.__strict_utf8 = strict_utf8
2846 self.__email_max_line_length = email_max_line_length
2847 self.__max_subject_length = max_subject_length
2849 def filter_body(self, lines):
2850 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2851 if self.__strict_utf8:
2852 if not PYTHON3:
2853 lines = (line.decode(ENCODING, 'replace') for line in lines)
2854 # Limit the line length in Unicode-space to avoid
2855 # splitting characters:
2856 if self.__email_max_line_length > 0:
2857 lines = limit_linelength(lines, self.__email_max_line_length)
2858 if not PYTHON3:
2859 lines = (line.encode(ENCODING, 'replace') for line in lines)
2860 elif self.__email_max_line_length:
2861 lines = limit_linelength(lines, self.__email_max_line_length)
2863 return lines
2865 def get_max_subject_length(self):
2866 return self.__max_subject_length
2869 class ConfigFilterLinesEnvironmentMixin(
2870 ConfigEnvironmentMixin,
2871 FilterLinesEnvironmentMixin,
2873 """Handle encoding and maximum line length based on config."""
2875 def __init__(self, config, **kw):
2876 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2877 if strict_utf8 is not None:
2878 kw['strict_utf8'] = strict_utf8
2880 email_max_line_length = config.get('emailmaxlinelength')
2881 if email_max_line_length is not None:
2882 kw['email_max_line_length'] = int(email_max_line_length)
2884 max_subject_length = config.get('subjectMaxLength', default=email_max_line_length)
2885 if max_subject_length is not None:
2886 kw['max_subject_length'] = int(max_subject_length)
2888 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2889 config=config, **kw
2893 class MaxlinesEnvironmentMixin(Environment):
2894 """Limit the email body to a specified number of lines."""
2896 def __init__(self, emailmaxlines, **kw):
2897 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2898 self.__emailmaxlines = emailmaxlines
2900 def filter_body(self, lines):
2901 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2902 if self.__emailmaxlines > 0:
2903 lines = limit_lines(lines, self.__emailmaxlines)
2904 return lines
2907 class ConfigMaxlinesEnvironmentMixin(
2908 ConfigEnvironmentMixin,
2909 MaxlinesEnvironmentMixin,
2911 """Limit the email body to the number of lines specified in config."""
2913 def __init__(self, config, **kw):
2914 emailmaxlines = int(config.get('emailmaxlines', default='0'))
2915 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2916 config=config,
2917 emailmaxlines=emailmaxlines,
2918 **kw
2922 class FQDNEnvironmentMixin(Environment):
2923 """A mixin that sets the host's FQDN to its constructor argument."""
2925 def __init__(self, fqdn, **kw):
2926 super(FQDNEnvironmentMixin, self).__init__(**kw)
2927 self.COMPUTED_KEYS += ['fqdn']
2928 self.__fqdn = fqdn
2930 def get_fqdn(self):
2931 """Return the fully-qualified domain name for this host.
2933 Return None if it is unavailable or unwanted."""
2935 return self.__fqdn
2938 class ConfigFQDNEnvironmentMixin(
2939 ConfigEnvironmentMixin,
2940 FQDNEnvironmentMixin,
2942 """Read the FQDN from the config."""
2944 def __init__(self, config, **kw):
2945 fqdn = config.get('fqdn')
2946 super(ConfigFQDNEnvironmentMixin, self).__init__(
2947 config=config,
2948 fqdn=fqdn,
2949 **kw
2953 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2954 """Get the FQDN by calling socket.getfqdn()."""
2956 def __init__(self, **kw):
2957 super(ComputeFQDNEnvironmentMixin, self).__init__(
2958 fqdn=socket.getfqdn(),
2959 **kw
2963 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2964 """Deduce pusher_email from pusher by appending an emaildomain."""
2966 def __init__(self, **kw):
2967 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2968 self.__emaildomain = self.config.get('emaildomain')
2970 def get_pusher_email(self):
2971 if self.__emaildomain:
2972 # Derive the pusher's full email address in the default way:
2973 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2974 else:
2975 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2978 class StaticRecipientsEnvironmentMixin(Environment):
2979 """Set recipients statically based on constructor parameters."""
2981 def __init__(
2982 self,
2983 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2984 **kw
2986 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2988 # The recipients for various types of notification emails, as
2989 # RFC 2822 email addresses separated by commas (or the empty
2990 # string if no recipients are configured). Although there is
2991 # a mechanism to choose the recipient lists based on on the
2992 # actual *contents* of the change being reported, we only
2993 # choose based on the *type* of the change. Therefore we can
2994 # compute them once and for all:
2995 self.__refchange_recipients = refchange_recipients
2996 self.__announce_recipients = announce_recipients
2997 self.__revision_recipients = revision_recipients
2999 def check(self):
3000 if not (self.get_refchange_recipients(None) or
3001 self.get_announce_recipients(None) or
3002 self.get_revision_recipients(None) or
3003 self.get_scancommitforcc()):
3004 raise ConfigurationException('No email recipients configured!')
3005 super(StaticRecipientsEnvironmentMixin, self).check()
3007 def get_refchange_recipients(self, refchange):
3008 if self.__refchange_recipients is None:
3009 return super(StaticRecipientsEnvironmentMixin,
3010 self).get_refchange_recipients(refchange)
3011 return self.__refchange_recipients
3013 def get_announce_recipients(self, annotated_tag_change):
3014 if self.__announce_recipients is None:
3015 return super(StaticRecipientsEnvironmentMixin,
3016 self).get_refchange_recipients(annotated_tag_change)
3017 return self.__announce_recipients
3019 def get_revision_recipients(self, revision):
3020 if self.__revision_recipients is None:
3021 return super(StaticRecipientsEnvironmentMixin,
3022 self).get_refchange_recipients(revision)
3023 return self.__revision_recipients
3026 class CLIRecipientsEnvironmentMixin(Environment):
3027 """Mixin storing recipients information coming from the
3028 command-line."""
3030 def __init__(self, cli_recipients=None, **kw):
3031 super(CLIRecipientsEnvironmentMixin, self).__init__(**kw)
3032 self.__cli_recipients = cli_recipients
3034 def get_refchange_recipients(self, refchange):
3035 if self.__cli_recipients is None:
3036 return super(CLIRecipientsEnvironmentMixin,
3037 self).get_refchange_recipients(refchange)
3038 return self.__cli_recipients
3040 def get_announce_recipients(self, annotated_tag_change):
3041 if self.__cli_recipients is None:
3042 return super(CLIRecipientsEnvironmentMixin,
3043 self).get_announce_recipients(annotated_tag_change)
3044 return self.__cli_recipients
3046 def get_revision_recipients(self, revision):
3047 if self.__cli_recipients is None:
3048 return super(CLIRecipientsEnvironmentMixin,
3049 self).get_revision_recipients(revision)
3050 return self.__cli_recipients
3053 class ConfigRecipientsEnvironmentMixin(
3054 ConfigEnvironmentMixin,
3055 StaticRecipientsEnvironmentMixin
3057 """Determine recipients statically based on config."""
3059 def __init__(self, config, **kw):
3060 super(ConfigRecipientsEnvironmentMixin, self).__init__(
3061 config=config,
3062 refchange_recipients=self._get_recipients(
3063 config, 'refchangelist', 'mailinglist',
3065 announce_recipients=self._get_recipients(
3066 config, 'announcelist', 'refchangelist', 'mailinglist',
3068 revision_recipients=self._get_recipients(
3069 config, 'commitlist', 'mailinglist',
3071 scancommitforcc=config.get('scancommitforcc'),
3072 **kw
3075 def _get_recipients(self, config, *names):
3076 """Return the recipients for a particular type of message.
3078 Return the list of email addresses to which a particular type
3079 of notification email should be sent, by looking at the config
3080 value for "multimailhook.$name" for each of names. Use the
3081 value from the first name that is configured. The return
3082 value is a (possibly empty) string containing RFC 2822 email
3083 addresses separated by commas. If no configuration could be
3084 found, raise a ConfigurationException."""
3086 for name in names:
3087 lines = config.get_all(name)
3088 if lines is not None:
3089 lines = [line.strip() for line in lines]
3090 # Single "none" is a special value equivalen to empty string.
3091 if lines == ['none']:
3092 lines = ['']
3093 return ', '.join(lines)
3094 else:
3095 return ''
3098 class StaticRefFilterEnvironmentMixin(Environment):
3099 """Set branch filter statically based on constructor parameters."""
3101 def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
3102 ref_filter_do_send_regex, ref_filter_dont_send_regex,
3103 **kw):
3104 super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
3106 if ref_filter_incl_regex and ref_filter_excl_regex:
3107 raise ConfigurationException(
3108 "Cannot specify both a ref inclusion and exclusion regex.")
3109 self.__is_inclusion_filter = bool(ref_filter_incl_regex)
3110 default_exclude = self.get_default_ref_ignore_regex()
3111 if ref_filter_incl_regex:
3112 ref_filter_regex = ref_filter_incl_regex
3113 elif ref_filter_excl_regex:
3114 ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
3115 else:
3116 ref_filter_regex = default_exclude
3117 try:
3118 self.__compiled_regex = re.compile(ref_filter_regex)
3119 except Exception:
3120 raise ConfigurationException(
3121 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
3123 if ref_filter_do_send_regex and ref_filter_dont_send_regex:
3124 raise ConfigurationException(
3125 "Cannot specify both a ref doSend and dontSend regex.")
3126 self.__is_do_send_filter = bool(ref_filter_do_send_regex)
3127 if ref_filter_do_send_regex:
3128 ref_filter_send_regex = ref_filter_do_send_regex
3129 elif ref_filter_dont_send_regex:
3130 ref_filter_send_regex = ref_filter_dont_send_regex
3131 else:
3132 ref_filter_send_regex = '.*'
3133 self.__is_do_send_filter = True
3134 try:
3135 self.__send_compiled_regex = re.compile(ref_filter_send_regex)
3136 except Exception:
3137 raise ConfigurationException(
3138 'Invalid Ref Filter Regex "%s": %s' %
3139 (ref_filter_send_regex, sys.exc_info()[1]))
3141 def get_ref_filter_regex(self, send_filter=False):
3142 if send_filter:
3143 return self.__send_compiled_regex, self.__is_do_send_filter
3144 else:
3145 return self.__compiled_regex, self.__is_inclusion_filter
3148 class ConfigRefFilterEnvironmentMixin(
3149 ConfigEnvironmentMixin,
3150 StaticRefFilterEnvironmentMixin
3152 """Determine branch filtering statically based on config."""
3154 def _get_regex(self, config, key):
3155 """Get a list of whitespace-separated regex. The refFilter* config
3156 variables are multivalued (hence the use of get_all), and we
3157 allow each entry to be a whitespace-separated list (hence the
3158 split on each line). The whole thing is glued into a single regex."""
3159 values = config.get_all(key)
3160 if values is None:
3161 return values
3162 items = []
3163 for line in values:
3164 for i in line.split():
3165 items.append(i)
3166 if items == []:
3167 return None
3168 return '|'.join(items)
3170 def __init__(self, config, **kw):
3171 super(ConfigRefFilterEnvironmentMixin, self).__init__(
3172 config=config,
3173 ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
3174 ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
3175 ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
3176 ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
3177 **kw
3181 class ProjectdescEnvironmentMixin(Environment):
3182 """Make a "projectdesc" value available for templates.
3184 By default, it is set to the first line of $GIT_DIR/description
3185 (if that file is present and appears to be set meaningfully)."""
3187 def __init__(self, **kw):
3188 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
3189 self.COMPUTED_KEYS += ['projectdesc']
3191 def get_projectdesc(self):
3192 """Return a one-line descripition of the project."""
3194 git_dir = get_git_dir()
3195 try:
3196 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
3197 if projectdesc and not projectdesc.startswith('Unnamed repository'):
3198 return projectdesc
3199 except IOError:
3200 pass
3202 return 'UNNAMED PROJECT'
3205 class GenericEnvironmentMixin(Environment):
3206 def get_pusher(self):
3207 return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
3210 class GitoliteEnvironmentHighPrecMixin(Environment):
3211 def get_pusher(self):
3212 return self.osenv.get('GL_USER', 'unknown user')
3215 class GitoliteEnvironmentLowPrecMixin(
3216 ConfigEnvironmentMixin,
3217 Environment):
3219 def get_repo_shortname(self):
3220 # The gitolite environment variable $GL_REPO is a pretty good
3221 # repo_shortname (though it's probably not as good as a value
3222 # the user might have explicitly put in his config).
3223 return (
3224 self.osenv.get('GL_REPO', None) or
3225 super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname()
3228 @staticmethod
3229 def _compile_regex(re_template):
3230 return (
3231 re.compile(re_template % x)
3232 for x in (
3233 r'BEGIN\s+USER\s+EMAILS',
3234 r'([^\s]+)\s+(.*)',
3235 r'END\s+USER\s+EMAILS',
3238 def get_fromaddr(self, change=None):
3239 GL_USER = self.osenv.get('GL_USER')
3240 if GL_USER is not None:
3241 # Find the path to gitolite.conf. Note that gitolite v3
3242 # did away with the GL_ADMINDIR and GL_CONF environment
3243 # variables (they are now hard-coded).
3244 GL_ADMINDIR = self.osenv.get(
3245 'GL_ADMINDIR',
3246 os.path.expanduser(os.path.join('~', '.gitolite')))
3247 GL_CONF = self.osenv.get(
3248 'GL_CONF',
3249 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
3251 mailaddress_map = self.config.get('MailaddressMap')
3252 # If relative, consider relative to GL_CONF:
3253 if mailaddress_map:
3254 mailaddress_map = os.path.join(os.path.dirname(GL_CONF),
3255 mailaddress_map)
3256 if os.path.isfile(mailaddress_map):
3257 f = open(mailaddress_map, 'rU')
3258 try:
3259 # Leading '#' is optional
3260 re_begin, re_user, re_end = self._compile_regex(
3261 r'^(?:\s*#)?\s*%s\s*$')
3262 for l in f:
3263 l = l.rstrip('\n')
3264 if re_begin.match(l) or re_end.match(l):
3265 continue # Ignore these lines
3266 m = re_user.match(l)
3267 if m:
3268 if m.group(1) == GL_USER:
3269 return m.group(2)
3270 else:
3271 continue # Not this user, but not an error
3272 raise ConfigurationException(
3273 "Syntax error in mail address map.\n"
3274 "Check file {}.\n"
3275 "Line: {}".format(mailaddress_map, l))
3277 finally:
3278 f.close()
3280 if os.path.isfile(GL_CONF):
3281 f = open(GL_CONF, 'rU')
3282 try:
3283 in_user_emails_section = False
3284 re_begin, re_user, re_end = self._compile_regex(
3285 r'^\s*#\s*%s\s*$')
3286 for l in f:
3287 l = l.rstrip('\n')
3288 if not in_user_emails_section:
3289 if re_begin.match(l):
3290 in_user_emails_section = True
3291 continue
3292 if re_end.match(l):
3293 break
3294 m = re_user.match(l)
3295 if m and m.group(1) == GL_USER:
3296 return m.group(2)
3297 finally:
3298 f.close()
3299 return super(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change)
3302 class IncrementalDateTime(object):
3303 """Simple wrapper to give incremental date/times.
3305 Each call will result in a date/time a second later than the
3306 previous call. This can be used to falsify email headers, to
3307 increase the likelihood that email clients sort the emails
3308 correctly."""
3310 def __init__(self):
3311 self.time = time.time()
3312 self.next = self.__next__ # Python 2 backward compatibility
3314 def __next__(self):
3315 formatted = formatdate(self.time, True)
3316 self.time += 1
3317 return formatted
3320 class StashEnvironmentHighPrecMixin(Environment):
3321 def __init__(self, user=None, repo=None, **kw):
3322 super(StashEnvironmentHighPrecMixin,
3323 self).__init__(user=user, repo=repo, **kw)
3324 self.__user = user
3325 self.__repo = repo
3327 def get_pusher(self):
3328 return re.match(r'(.*?)\s*<', self.__user).group(1)
3330 def get_pusher_email(self):
3331 return self.__user
3334 class StashEnvironmentLowPrecMixin(Environment):
3335 def __init__(self, user=None, repo=None, **kw):
3336 super(StashEnvironmentLowPrecMixin, self).__init__(**kw)
3337 self.__repo = repo
3338 self.__user = user
3340 def get_repo_shortname(self):
3341 return self.__repo
3343 def get_fromaddr(self, change=None):
3344 return self.__user
3347 class GerritEnvironmentHighPrecMixin(Environment):
3348 def __init__(self, project=None, submitter=None, update_method=None, **kw):
3349 super(GerritEnvironmentHighPrecMixin,
3350 self).__init__(submitter=submitter, project=project, **kw)
3351 self.__project = project
3352 self.__submitter = submitter
3353 self.__update_method = update_method
3354 "Make an 'update_method' value available for templates."
3355 self.COMPUTED_KEYS += ['update_method']
3357 def get_pusher(self):
3358 if self.__submitter:
3359 if self.__submitter.find('<') != -1:
3360 # Submitter has a configured email, we transformed
3361 # __submitter into an RFC 2822 string already.
3362 return re.match(r'(.*?)\s*<', self.__submitter).group(1)
3363 else:
3364 # Submitter has no configured email, it's just his name.
3365 return self.__submitter
3366 else:
3367 # If we arrive here, this means someone pushed "Submit" from
3368 # the gerrit web UI for the CR (or used one of the programmatic
3369 # APIs to do the same, such as gerrit review) and the
3370 # merge/push was done by the Gerrit user. It was technically
3371 # triggered by someone else, but sadly we have no way of
3372 # determining who that someone else is at this point.
3373 return 'Gerrit' # 'unknown user'?
3375 def get_pusher_email(self):
3376 if self.__submitter:
3377 return self.__submitter
3378 else:
3379 return super(GerritEnvironmentHighPrecMixin, self).get_pusher_email()
3381 def get_default_ref_ignore_regex(self):
3382 default = super(GerritEnvironmentHighPrecMixin, self).get_default_ref_ignore_regex()
3383 return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3385 def get_revision_recipients(self, revision):
3386 # Merge commits created by Gerrit when users hit "Submit this patchset"
3387 # in the Web UI (or do equivalently with REST APIs or the gerrit review
3388 # command) are not something users want to see an individual email for.
3389 # Filter them out.
3390 committer = read_git_output(['log', '--no-walk', '--format=%cN',
3391 revision.rev.sha1])
3392 if committer == 'Gerrit Code Review':
3393 return []
3394 else:
3395 return super(GerritEnvironmentHighPrecMixin, self).get_revision_recipients(revision)
3397 def get_update_method(self):
3398 return self.__update_method
3401 class GerritEnvironmentLowPrecMixin(Environment):
3402 def __init__(self, project=None, submitter=None, **kw):
3403 super(GerritEnvironmentLowPrecMixin, self).__init__(**kw)
3404 self.__project = project
3405 self.__submitter = submitter
3407 def get_repo_shortname(self):
3408 return self.__project
3410 def get_fromaddr(self, change=None):
3411 if self.__submitter and self.__submitter.find('<') != -1:
3412 return self.__submitter
3413 else:
3414 return super(GerritEnvironmentLowPrecMixin, self).get_fromaddr(change)
3417 class Push(object):
3418 """Represent an entire push (i.e., a group of ReferenceChanges).
3420 It is easy to figure out what commits were added to a *branch* by
3421 a Reference change:
3423 git rev-list change.old..change.new
3425 or removed from a *branch*:
3427 git rev-list change.new..change.old
3429 But it is not quite so trivial to determine which entirely new
3430 commits were added to the *repository* by a push and which old
3431 commits were discarded by a push. A big part of the job of this
3432 class is to figure out these things, and to make sure that new
3433 commits are only detailed once even if they were added to multiple
3434 references.
3436 The first step is to determine the "other" references--those
3437 unaffected by the current push. They are computed by listing all
3438 references then removing any affected by this push. The results
3439 are stored in Push._other_ref_sha1s.
3441 The commits contained in the repository before this push were
3443 git rev-list other1 other2 other3 ... change1.old change2.old ...
3445 Where "changeN.old" is the old value of one of the references
3446 affected by this push.
3448 The commits contained in the repository after this push are
3450 git rev-list other1 other2 other3 ... change1.new change2.new ...
3452 The commits added by this push are the difference between these
3453 two sets, which can be written
3455 git rev-list \
3456 ^other1 ^other2 ... \
3457 ^change1.old ^change2.old ... \
3458 change1.new change2.new ...
3460 The commits removed by this push can be computed by
3462 git rev-list \
3463 ^other1 ^other2 ... \
3464 ^change1.new ^change2.new ... \
3465 change1.old change2.old ...
3467 The last point is that it is possible that other pushes are
3468 occurring simultaneously to this one, so reference values can
3469 change at any time. It is impossible to eliminate all race
3470 conditions, but we reduce the window of time during which problems
3471 can occur by translating reference names to SHA1s as soon as
3472 possible and working with SHA1s thereafter (because SHA1s are
3473 immutable)."""
3475 # A map {(changeclass, changetype): integer} specifying the order
3476 # that reference changes will be processed if multiple reference
3477 # changes are included in a single push. The order is significant
3478 # mostly because new commit notifications are threaded together
3479 # with the first reference change that includes the commit. The
3480 # following order thus causes commits to be grouped with branch
3481 # changes (as opposed to tag changes) if possible.
3482 SORT_ORDER = dict(
3483 (value, i) for (i, value) in enumerate([
3484 (BranchChange, 'update'),
3485 (BranchChange, 'create'),
3486 (AnnotatedTagChange, 'update'),
3487 (AnnotatedTagChange, 'create'),
3488 (NonAnnotatedTagChange, 'update'),
3489 (NonAnnotatedTagChange, 'create'),
3490 (BranchChange, 'delete'),
3491 (AnnotatedTagChange, 'delete'),
3492 (NonAnnotatedTagChange, 'delete'),
3493 (OtherReferenceChange, 'update'),
3494 (OtherReferenceChange, 'create'),
3495 (OtherReferenceChange, 'delete'),
3499 def __init__(self, environment, changes, ignore_other_refs=False):
3500 self.changes = sorted(changes, key=self._sort_key)
3501 self.__other_ref_sha1s = None
3502 self.__cached_commits_spec = {}
3503 self.environment = environment
3505 if ignore_other_refs:
3506 self.__other_ref_sha1s = set()
3508 @classmethod
3509 def _sort_key(klass, change):
3510 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
3512 @property
3513 def _other_ref_sha1s(self):
3514 """The GitObjects referred to by references unaffected by this push.
3516 if self.__other_ref_sha1s is None:
3517 # The refnames being changed by this push:
3518 updated_refs = set(
3519 change.refname
3520 for change in self.changes
3523 # The SHA-1s of commits referred to by all references in this
3524 # repository *except* updated_refs:
3525 sha1s = set()
3526 fmt = (
3527 '%(objectname) %(objecttype) %(refname)\n'
3528 '%(*objectname) %(*objecttype) %(refname)'
3530 ref_filter_regex, is_inclusion_filter = \
3531 self.environment.get_ref_filter_regex()
3532 for line in read_git_lines(
3533 ['for-each-ref', '--format=%s' % (fmt,)]):
3534 (sha1, type, name) = line.split(' ', 2)
3535 if (sha1 and type == 'commit' and
3536 name not in updated_refs and
3537 include_ref(name, ref_filter_regex, is_inclusion_filter)):
3538 sha1s.add(sha1)
3540 self.__other_ref_sha1s = sha1s
3542 return self.__other_ref_sha1s
3544 def _get_commits_spec_incl(self, new_or_old, reference_change=None):
3545 """Get new or old SHA-1 from one or each of the changed refs.
3547 Return a list of SHA-1 commit identifier strings suitable as
3548 arguments to 'git rev-list' (or 'git log' or ...). The
3549 returned identifiers are either the old or new values from one
3550 or all of the changed references, depending on the values of
3551 new_or_old and reference_change.
3553 new_or_old is either the string 'new' or the string 'old'. If
3554 'new', the returned SHA-1 identifiers are the new values from
3555 each changed reference. If 'old', the SHA-1 identifiers are
3556 the old values from each changed reference.
3558 If reference_change is specified and not None, only the new or
3559 old reference from the specified reference is included in the
3560 return value.
3562 This function returns None if there are no matching revisions
3563 (e.g., because a branch was deleted and new_or_old is 'new').
3566 if not reference_change:
3567 incl_spec = sorted(
3568 getattr(change, new_or_old).sha1
3569 for change in self.changes
3570 if getattr(change, new_or_old)
3572 if not incl_spec:
3573 incl_spec = None
3574 elif not getattr(reference_change, new_or_old).commit_sha1:
3575 incl_spec = None
3576 else:
3577 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
3578 return incl_spec
3580 def _get_commits_spec_excl(self, new_or_old):
3581 """Get exclusion revisions for determining new or discarded commits.
3583 Return a list of strings suitable as arguments to 'git
3584 rev-list' (or 'git log' or ...) that will exclude all
3585 commits that, depending on the value of new_or_old, were
3586 either previously in the repository (useful for determining
3587 which commits are new to the repository) or currently in the
3588 repository (useful for determining which commits were
3589 discarded from the repository).
3591 new_or_old is either the string 'new' or the string 'old'. If
3592 'new', the commits to be excluded are those that were in the
3593 repository before the push. If 'old', the commits to be
3594 excluded are those that are currently in the repository. """
3596 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
3597 excl_revs = self._other_ref_sha1s.union(
3598 getattr(change, old_or_new).sha1
3599 for change in self.changes
3600 if getattr(change, old_or_new).type in ['commit', 'tag']
3602 return ['^' + sha1 for sha1 in sorted(excl_revs)]
3604 def get_commits_spec(self, new_or_old, reference_change=None):
3605 """Get rev-list arguments for added or discarded commits.
3607 Return a list of strings suitable as arguments to 'git
3608 rev-list' (or 'git log' or ...) that select those commits
3609 that, depending on the value of new_or_old, are either new to
3610 the repository or were discarded from the repository.
3612 new_or_old is either the string 'new' or the string 'old'. If
3613 'new', the returned list is used to select commits that are
3614 new to the repository. If 'old', the returned value is used
3615 to select the commits that have been discarded from the
3616 repository.
3618 If reference_change is specified and not None, the new or
3619 discarded commits are limited to those that are reachable from
3620 the new or old value of the specified reference.
3622 This function returns None if there are no added (or discarded)
3623 revisions.
3625 key = (new_or_old, reference_change)
3626 if key not in self.__cached_commits_spec:
3627 ret = self._get_commits_spec_incl(new_or_old, reference_change)
3628 if ret is not None:
3629 ret.extend(self._get_commits_spec_excl(new_or_old))
3630 self.__cached_commits_spec[key] = ret
3631 return self.__cached_commits_spec[key]
3633 def get_new_commits(self, reference_change=None):
3634 """Return a list of commits added by this push.
3636 Return a list of the object names of commits that were added
3637 by the part of this push represented by reference_change. If
3638 reference_change is None, then return a list of *all* commits
3639 added by this push."""
3641 spec = self.get_commits_spec('new', reference_change)
3642 return git_rev_list(spec)
3644 def get_discarded_commits(self, reference_change):
3645 """Return a list of commits discarded by this push.
3647 Return a list of the object names of commits that were
3648 entirely discarded from the repository by the part of this
3649 push represented by reference_change."""
3651 spec = self.get_commits_spec('old', reference_change)
3652 return git_rev_list(spec)
3654 def send_emails(self, mailer, body_filter=None):
3655 """Use send all of the notification emails needed for this push.
3657 Use send all of the notification emails (including reference
3658 change emails and commit emails) needed for this push. Send
3659 the emails using mailer. If body_filter is not None, then use
3660 it to filter the lines that are intended for the email
3661 body."""
3663 # The sha1s of commits that were introduced by this push.
3664 # They will be removed from this set as they are processed, to
3665 # guarantee that one (and only one) email is generated for
3666 # each new commit.
3667 unhandled_sha1s = set(self.get_new_commits())
3668 send_date = IncrementalDateTime()
3669 for change in self.changes:
3670 sha1s = []
3671 for sha1 in reversed(list(self.get_new_commits(change))):
3672 if sha1 in unhandled_sha1s:
3673 sha1s.append(sha1)
3674 unhandled_sha1s.remove(sha1)
3676 # Check if we've got anyone to send to
3677 if not change.recipients:
3678 change.environment.log_warning(
3679 '*** no recipients configured so no email will be sent\n'
3680 '*** for %r update %s->%s'
3681 % (change.refname, change.old.sha1, change.new.sha1,)
3683 else:
3684 if not change.environment.quiet:
3685 change.environment.log_msg(
3686 'Sending notification emails to: %s' % (change.recipients,))
3687 extra_values = {'send_date': next(send_date)}
3689 rev = change.send_single_combined_email(sha1s)
3690 if rev:
3691 mailer.send(
3692 change.generate_combined_email(self, rev, body_filter, extra_values),
3693 rev.recipients,
3695 # This change is now fully handled; no need to handle
3696 # individual revisions any further.
3697 continue
3698 else:
3699 mailer.send(
3700 change.generate_email(self, body_filter, extra_values),
3701 change.recipients,
3704 max_emails = change.environment.maxcommitemails
3705 if max_emails and len(sha1s) > max_emails:
3706 change.environment.log_warning(
3707 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3708 '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3709 '*** Currently, multimailhook.maxCommitEmails=%d' % max_emails
3711 return
3713 for (num, sha1) in enumerate(sha1s):
3714 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
3715 if len(rev.parents) > 1 and change.environment.excludemergerevisions:
3716 # skipping a merge commit
3717 continue
3718 if not rev.recipients and rev.cc_recipients:
3719 change.environment.log_msg('*** Replacing Cc: with To:')
3720 rev.recipients = rev.cc_recipients
3721 rev.cc_recipients = None
3722 if rev.recipients:
3723 extra_values = {'send_date': next(send_date)}
3724 mailer.send(
3725 rev.generate_email(self, body_filter, extra_values),
3726 rev.recipients,
3729 # Consistency check:
3730 if unhandled_sha1s:
3731 change.environment.log_error(
3732 'ERROR: No emails were sent for the following new commits:\n'
3733 ' %s'
3734 % ('\n '.join(sorted(unhandled_sha1s)),)
3738 def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3739 does_match = bool(ref_filter_regex.search(refname))
3740 if is_inclusion_filter:
3741 return does_match
3742 else: # exclusion filter -- we include the ref if the regex doesn't match
3743 return not does_match
3746 def run_as_post_receive_hook(environment, mailer):
3747 environment.check()
3748 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)
3749 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)
3750 changes = []
3751 while True:
3752 line = read_line(sys.stdin)
3753 if line == '':
3754 break
3755 (oldrev, newrev, refname) = line.strip().split(' ', 2)
3756 environment.get_logger().debug(
3757 "run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s" %
3758 (oldrev, newrev, refname))
3760 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3761 continue
3762 if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):
3763 continue
3764 changes.append(
3765 ReferenceChange.create(environment, oldrev, newrev, refname)
3767 if not changes:
3768 mailer.close()
3769 return
3770 push = Push(environment, changes)
3771 try:
3772 push.send_emails(mailer, body_filter=environment.filter_body)
3773 finally:
3774 mailer.close()
3777 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3778 environment.check()
3779 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)
3780 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)
3781 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3782 return
3783 if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):
3784 return
3785 changes = [
3786 ReferenceChange.create(
3787 environment,
3788 read_git_output(['rev-parse', '--verify', oldrev]),
3789 read_git_output(['rev-parse', '--verify', newrev]),
3790 refname,
3793 if not changes:
3794 mailer.close()
3795 return
3796 push = Push(environment, changes, force_send)
3797 try:
3798 push.send_emails(mailer, body_filter=environment.filter_body)
3799 finally:
3800 mailer.close()
3803 def check_ref_filter(environment):
3804 send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(True)
3805 ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(False)
3807 def inc_exc_lusion(b):
3808 if b:
3809 return 'inclusion'
3810 else:
3811 return 'exclusion'
3813 if send_filter_regex:
3814 sys.stdout.write("DoSend/DontSend filter regex (" +
3815 (inc_exc_lusion(send_is_inclusion)) +
3816 '): ' + send_filter_regex.pattern +
3817 '\n')
3818 if send_filter_regex:
3819 sys.stdout.write("Include/Exclude filter regex (" +
3820 (inc_exc_lusion(ref_is_inclusion)) +
3821 '): ' + ref_filter_regex.pattern +
3822 '\n')
3823 sys.stdout.write(os.linesep)
3825 sys.stdout.write(
3826 "Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n"
3827 "or refFilterExclusionRegex. No emails will be sent for commits included\n"
3828 "in these refs.\n"
3829 "Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n"
3830 "refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n"
3831 "refFilterExclusionRegex. Emails will be sent for commits included in these\n"
3832 "refs only when the commit reaches a ref which isn't excluded.\n"
3833 "Refs marked as DO-SEND are not excluded by any filter. Emails will\n"
3834 "be sent normally for commits included in these refs.\n")
3836 sys.stdout.write(os.linesep)
3838 for refname in read_git_lines(['for-each-ref', '--format', '%(refname)']):
3839 sys.stdout.write(refname)
3840 if not include_ref(refname, ref_filter_regex, ref_is_inclusion):
3841 sys.stdout.write(' EXCLUDE')
3842 elif not include_ref(refname, send_filter_regex, send_is_inclusion):
3843 sys.stdout.write(' DONT-SEND')
3844 else:
3845 sys.stdout.write(' DO-SEND')
3847 sys.stdout.write(os.linesep)
3850 def show_env(environment, out):
3851 out.write('Environment values:\n')
3852 for (k, v) in sorted(environment.get_values().items()):
3853 if k: # Don't show the {'' : ''} pair.
3854 out.write(' %s : %r\n' % (k, v))
3855 out.write('\n')
3856 # Flush to avoid interleaving with further log output
3857 out.flush()
3860 def check_setup(environment):
3861 environment.check()
3862 show_env(environment, sys.stdout)
3863 sys.stdout.write("Now, checking that git-multimail's standard input "
3864 "is properly set ..." + os.linesep)
3865 sys.stdout.write("Please type some text and then press Return" + os.linesep)
3866 stdin = sys.stdin.readline()
3867 sys.stdout.write("You have just entered:" + os.linesep)
3868 sys.stdout.write(stdin)
3869 sys.stdout.write("git-multimail seems properly set up." + os.linesep)
3872 def choose_mailer(config, environment):
3873 mailer = config.get('mailer', default='sendmail')
3875 if mailer == 'smtp':
3876 smtpserver = config.get('smtpserver', default='localhost')
3877 smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
3878 smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
3879 smtpencryption = config.get('smtpencryption', default='none')
3880 smtpuser = config.get('smtpuser', default='')
3881 smtppass = config.get('smtppass', default='')
3882 smtpcacerts = config.get('smtpcacerts', default='')
3883 mailer = SMTPMailer(
3884 environment,
3885 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
3886 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
3887 smtpserverdebuglevel=smtpserverdebuglevel,
3888 smtpencryption=smtpencryption,
3889 smtpuser=smtpuser,
3890 smtppass=smtppass,
3891 smtpcacerts=smtpcacerts
3893 elif mailer == 'sendmail':
3894 command = config.get('sendmailcommand')
3895 if command:
3896 command = shlex.split(command)
3897 mailer = SendMailer(environment,
3898 command=command, envelopesender=environment.get_sender())
3899 else:
3900 environment.log_error(
3901 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3902 'please use one of "smtp" or "sendmail".'
3904 sys.exit(1)
3905 return mailer
3908 KNOWN_ENVIRONMENTS = {
3909 'generic': {'highprec': GenericEnvironmentMixin},
3910 'gitolite': {'highprec': GitoliteEnvironmentHighPrecMixin,
3911 'lowprec': GitoliteEnvironmentLowPrecMixin},
3912 'stash': {'highprec': StashEnvironmentHighPrecMixin,
3913 'lowprec': StashEnvironmentLowPrecMixin},
3914 'gerrit': {'highprec': GerritEnvironmentHighPrecMixin,
3915 'lowprec': GerritEnvironmentLowPrecMixin},
3919 def choose_environment(config, osenv=None, env=None, recipients=None,
3920 hook_info=None):
3921 env_name = choose_environment_name(config, env, osenv)
3922 environment_klass = build_environment_klass(env_name)
3923 env = build_environment(environment_klass, env_name, config,
3924 osenv, recipients, hook_info)
3925 return env
3928 def choose_environment_name(config, env, osenv):
3929 if not osenv:
3930 osenv = os.environ
3932 if not env:
3933 env = config.get('environment')
3935 if not env:
3936 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
3937 env = 'gitolite'
3938 else:
3939 env = 'generic'
3940 return env
3943 COMMON_ENVIRONMENT_MIXINS = [
3944 ConfigRecipientsEnvironmentMixin,
3945 CLIRecipientsEnvironmentMixin,
3946 ConfigRefFilterEnvironmentMixin,
3947 ProjectdescEnvironmentMixin,
3948 ConfigMaxlinesEnvironmentMixin,
3949 ComputeFQDNEnvironmentMixin,
3950 ConfigFilterLinesEnvironmentMixin,
3951 PusherDomainEnvironmentMixin,
3952 ConfigOptionsEnvironmentMixin,
3956 def build_environment_klass(env_name):
3957 if 'class' in KNOWN_ENVIRONMENTS[env_name]:
3958 return KNOWN_ENVIRONMENTS[env_name]['class']
3960 environment_mixins = []
3961 known_env = KNOWN_ENVIRONMENTS[env_name]
3962 if 'highprec' in known_env:
3963 high_prec_mixin = known_env['highprec']
3964 environment_mixins.append(high_prec_mixin)
3965 environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS
3966 if 'lowprec' in known_env:
3967 low_prec_mixin = known_env['lowprec']
3968 environment_mixins.append(low_prec_mixin)
3969 environment_mixins.append(Environment)
3970 klass_name = env_name.capitalize() + 'Environment'
3971 environment_klass = type(
3972 klass_name,
3973 tuple(environment_mixins),
3976 KNOWN_ENVIRONMENTS[env_name]['class'] = environment_klass
3977 return environment_klass
3980 GerritEnvironment = build_environment_klass('gerrit')
3981 StashEnvironment = build_environment_klass('stash')
3982 GitoliteEnvironment = build_environment_klass('gitolite')
3983 GenericEnvironment = build_environment_klass('generic')
3986 def build_environment(environment_klass, env, config,
3987 osenv, recipients, hook_info):
3988 environment_kw = {
3989 'osenv': osenv,
3990 'config': config,
3993 if env == 'stash':
3994 environment_kw['user'] = hook_info['stash_user']
3995 environment_kw['repo'] = hook_info['stash_repo']
3996 elif env == 'gerrit':
3997 environment_kw['project'] = hook_info['project']
3998 environment_kw['submitter'] = hook_info['submitter']
3999 environment_kw['update_method'] = hook_info['update_method']
4001 environment_kw['cli_recipients'] = recipients
4003 return environment_klass(**environment_kw)
4006 def get_version():
4007 oldcwd = os.getcwd()
4008 try:
4009 try:
4010 os.chdir(os.path.dirname(os.path.realpath(__file__)))
4011 git_version = read_git_output(['describe', '--tags', 'HEAD'])
4012 if git_version == __version__:
4013 return git_version
4014 else:
4015 return '%s (%s)' % (__version__, git_version)
4016 except:
4017 pass
4018 finally:
4019 os.chdir(oldcwd)
4020 return __version__
4023 def compute_gerrit_options(options, args, required_gerrit_options,
4024 raw_refname):
4025 if None in required_gerrit_options:
4026 raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
4027 "and --project; or none of them.")
4029 if options.environment not in (None, 'gerrit'):
4030 raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
4031 "--newrev, --refname, and --project")
4032 options.environment = 'gerrit'
4034 if args:
4035 raise SystemExit("Error: Positional parameters not allowed with "
4036 "--oldrev, --newrev, and --refname.")
4038 # Gerrit oddly omits 'refs/heads/' in the refname when calling
4039 # ref-updated hook; put it back.
4040 git_dir = get_git_dir()
4041 if (not os.path.exists(os.path.join(git_dir, raw_refname)) and
4042 os.path.exists(os.path.join(git_dir, 'refs', 'heads',
4043 raw_refname))):
4044 options.refname = 'refs/heads/' + options.refname
4046 # New revisions can appear in a gerrit repository either due to someone
4047 # pushing directly (in which case options.submitter will be set), or they
4048 # can press "Submit this patchset" in the web UI for some CR (in which
4049 # case options.submitter will not be set and gerrit will not have provided
4050 # us the information about who pressed the button).
4052 # Note for the nit-picky: I'm lumping in REST API calls and the ssh
4053 # gerrit review command in with "Submit this patchset" button, since they
4054 # have the same effect.
4055 if options.submitter:
4056 update_method = 'pushed'
4057 # The submitter argument is almost an RFC 2822 email address; change it
4058 # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
4059 options.submitter = options.submitter.replace('(', '<').replace(')', '>')
4060 else:
4061 update_method = 'submitted'
4062 # Gerrit knew who submitted this patchset, but threw that information
4063 # away when it invoked this hook. However, *IF* Gerrit created a
4064 # merge to bring the patchset in (project 'Submit Type' is either
4065 # "Always Merge", or is "Merge if Necessary" and happens to be
4066 # necessary for this particular CR), then it will have the committer
4067 # of that merge be 'Gerrit Code Review' and the author will be the
4068 # person who requested the submission of the CR. Since this is fairly
4069 # likely for most gerrit installations (of a reasonable size), it's
4070 # worth the extra effort to try to determine the actual submitter.
4071 rev_info = read_git_lines(['log', '--no-walk', '--merges',
4072 '--format=%cN%n%aN <%aE>', options.newrev])
4073 if rev_info and rev_info[0] == 'Gerrit Code Review':
4074 options.submitter = rev_info[1]
4076 # We pass back refname, oldrev, newrev as args because then the
4077 # gerrit ref-updated hook is much like the git update hook
4078 return (options,
4079 [options.refname, options.oldrev, options.newrev],
4080 {'project': options.project, 'submitter': options.submitter,
4081 'update_method': update_method})
4084 def check_hook_specific_args(options, args):
4085 raw_refname = options.refname
4086 # Convert each string option unicode for Python3.
4087 if PYTHON3:
4088 opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
4089 'project', 'submitter', 'stash_user', 'stash_repo']
4090 for opt in opts:
4091 if not hasattr(options, opt):
4092 continue
4093 obj = getattr(options, opt)
4094 if obj:
4095 enc = obj.encode('utf-8', 'surrogateescape')
4096 dec = enc.decode('utf-8', 'replace')
4097 setattr(options, opt, dec)
4099 # First check for stash arguments
4100 if (options.stash_user is None) != (options.stash_repo is None):
4101 raise SystemExit("Error: Specify both of --stash-user and "
4102 "--stash-repo or neither.")
4103 if options.stash_user:
4104 options.environment = 'stash'
4105 return options, args, {'stash_user': options.stash_user,
4106 'stash_repo': options.stash_repo}
4108 # Finally, check for gerrit specific arguments
4109 required_gerrit_options = (options.oldrev, options.newrev, options.refname,
4110 options.project)
4111 if required_gerrit_options != (None,) * 4:
4112 return compute_gerrit_options(options, args, required_gerrit_options,
4113 raw_refname)
4115 # No special options in use, just return what we started with
4116 return options, args, {}
4119 class Logger(object):
4120 def parse_verbose(self, verbose):
4121 if verbose > 0:
4122 return logging.DEBUG
4123 else:
4124 return logging.INFO
4126 def create_log_file(self, environment, name, path, verbosity):
4127 log_file = logging.getLogger(name)
4128 file_handler = logging.FileHandler(path)
4129 log_fmt = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s")
4130 file_handler.setFormatter(log_fmt)
4131 log_file.addHandler(file_handler)
4132 log_file.setLevel(verbosity)
4133 return log_file
4135 def __init__(self, environment):
4136 self.environment = environment
4137 self.loggers = []
4138 stderr_log = logging.getLogger('git_multimail.stderr')
4140 class EncodedStderr(object):
4141 def write(self, x):
4142 write_str(sys.stderr, x)
4144 def flush(self):
4145 sys.stderr.flush()
4147 stderr_handler = logging.StreamHandler(EncodedStderr())
4148 stderr_log.addHandler(stderr_handler)
4149 stderr_log.setLevel(self.parse_verbose(environment.verbose))
4150 self.loggers.append(stderr_log)
4152 if environment.debug_log_file is not None:
4153 debug_log_file = self.create_log_file(
4154 environment, 'git_multimail.debug', environment.debug_log_file, logging.DEBUG)
4155 self.loggers.append(debug_log_file)
4157 if environment.log_file is not None:
4158 log_file = self.create_log_file(
4159 environment, 'git_multimail.file', environment.log_file, logging.INFO)
4160 self.loggers.append(log_file)
4162 if environment.error_log_file is not None:
4163 error_log_file = self.create_log_file(
4164 environment, 'git_multimail.error', environment.error_log_file, logging.ERROR)
4165 self.loggers.append(error_log_file)
4167 def info(self, msg, *args, **kwargs):
4168 for l in self.loggers:
4169 l.info(msg, *args, **kwargs)
4171 def debug(self, msg, *args, **kwargs):
4172 for l in self.loggers:
4173 l.debug(msg, *args, **kwargs)
4175 def warning(self, msg, *args, **kwargs):
4176 for l in self.loggers:
4177 l.warning(msg, *args, **kwargs)
4179 def error(self, msg, *args, **kwargs):
4180 for l in self.loggers:
4181 l.error(msg, *args, **kwargs)
4184 def main(args):
4185 parser = optparse.OptionParser(
4186 description=__doc__,
4187 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
4190 parser.add_option(
4191 '--environment', '--env', action='store', type='choice',
4192 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
4193 help=(
4194 'Choose type of environment is in use. Default is taken from '
4195 'multimailhook.environment if set; otherwise "generic".'
4198 parser.add_option(
4199 '--stdout', action='store_true', default=False,
4200 help='Output emails to stdout rather than sending them.',
4202 parser.add_option(
4203 '--recipients', action='store', default=None,
4204 help='Set list of email recipients for all types of emails.',
4206 parser.add_option(
4207 '--show-env', action='store_true', default=False,
4208 help=(
4209 'Write to stderr the values determined for the environment '
4210 '(intended for debugging purposes), then proceed normally.'
4213 parser.add_option(
4214 '--force-send', action='store_true', default=False,
4215 help=(
4216 'Force sending refchange email when using as an update hook. '
4217 'This is useful to work around the unreliable new commits '
4218 'detection in this mode.'
4221 parser.add_option(
4222 '-c', metavar="<name>=<value>", action='append',
4223 help=(
4224 'Pass a configuration parameter through to git. The value given '
4225 'will override values from configuration files. See the -c option '
4226 'of git(1) for more details. (Only works with git >= 1.7.3)'
4229 parser.add_option(
4230 '--version', '-v', action='store_true', default=False,
4231 help=(
4232 "Display git-multimail's version"
4236 parser.add_option(
4237 '--python-version', action='store_true', default=False,
4238 help=(
4239 "Display the version of Python used by git-multimail"
4243 parser.add_option(
4244 '--check-ref-filter', action='store_true', default=False,
4245 help=(
4246 'List refs and show information on how git-multimail '
4247 'will process them.'
4251 # The following options permit this script to be run as a gerrit
4252 # ref-updated hook. See e.g.
4253 # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
4254 # We suppress help for these items, since these are specific to gerrit,
4255 # and we don't want users directly using them any way other than how the
4256 # gerrit ref-updated hook is called.
4257 parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
4258 parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
4259 parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
4260 parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
4261 parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
4263 # The following allow this to be run as a stash asynchronous post-receive
4264 # hook (almost identical to a git post-receive hook but triggered also for
4265 # merges of pull requests from the UI). We suppress help for these items,
4266 # since these are specific to stash.
4267 parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
4268 parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
4270 (options, args) = parser.parse_args(args)
4271 (options, args, hook_info) = check_hook_specific_args(options, args)
4273 if options.version:
4274 sys.stdout.write('git-multimail version ' + get_version() + '\n')
4275 return
4277 if options.python_version:
4278 sys.stdout.write('Python version ' + sys.version + '\n')
4279 return
4281 if options.c:
4282 Config.add_config_parameters(options.c)
4284 config = Config('multimailhook')
4286 environment = None
4287 try:
4288 environment = choose_environment(
4289 config, osenv=os.environ,
4290 env=options.environment,
4291 recipients=options.recipients,
4292 hook_info=hook_info,
4295 if options.show_env:
4296 show_env(environment, sys.stderr)
4298 if options.stdout or environment.stdout:
4299 mailer = OutputMailer(sys.stdout, environment)
4300 else:
4301 mailer = choose_mailer(config, environment)
4303 must_check_setup = os.environ.get('GIT_MULTIMAIL_CHECK_SETUP')
4304 if must_check_setup == '':
4305 must_check_setup = False
4306 if options.check_ref_filter:
4307 check_ref_filter(environment)
4308 elif must_check_setup:
4309 check_setup(environment)
4310 # Dual mode: if arguments were specified on the command line, run
4311 # like an update hook; otherwise, run as a post-receive hook.
4312 elif args:
4313 if len(args) != 3:
4314 parser.error('Need zero or three non-option arguments')
4315 (refname, oldrev, newrev) = args
4316 environment.get_logger().debug(
4317 "run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s" %
4318 (refname, oldrev, newrev, options.force_send))
4319 run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
4320 else:
4321 run_as_post_receive_hook(environment, mailer)
4322 except ConfigurationException:
4323 sys.exit(sys.exc_info()[1])
4324 except SystemExit:
4325 raise
4326 except Exception:
4327 t, e, tb = sys.exc_info()
4328 import traceback
4329 sys.stderr.write('\n') # Avoid mixing message with previous output
4330 msg = (
4331 'Exception \'' + t.__name__ +
4332 '\' raised. Please report this as a bug to\n'
4333 'https://github.com/git-multimail/git-multimail/issues\n'
4334 'with the information below:\n\n'
4335 'git-multimail version ' + get_version() + '\n'
4336 'Python version ' + sys.version + '\n' +
4337 traceback.format_exc())
4338 try:
4339 environment.get_logger().error(msg)
4340 except:
4341 sys.stderr.write(msg)
4342 sys.exit(1)
4345 if __name__ == '__main__':
4346 main(sys.argv[1:])